Add View::style(), #[derive(IntoViewElement)], and EditorView

Mikayla Maki created

Change summary

crates/gpui/examples/text_views/editor.rs          | 241 +++++++++++++++
crates/gpui/examples/text_views/editor_text.rs     | 200 -------------
crates/gpui/examples/text_views/input.rs           |  34 -
crates/gpui/examples/text_views/main.rs            |   1 
crates/gpui/examples/text_views/text_area.rs       |  30 -
crates/gpui/src/gpui.rs                            |   3 
crates/gpui/src/view.rs                            |  13 
crates/gpui_macros/src/derive_into_view_element.rs |  28 +
crates/gpui_macros/src/gpui_macros.rs              |   9 
9 files changed, 315 insertions(+), 244 deletions(-)

Detailed changes

crates/gpui/examples/text_views/editor.rs 🔗

@@ -1,16 +1,18 @@
 //! The `Editor` entity — owns the truth about text content, cursor position,
 //! blink state, and keyboard handling.
 //!
-//! This is pure state with no rendering. It implements `EntityInputHandler` so
-//! the platform can deliver typed characters, and `Focusable` so the window
-//! knows where keyboard focus lives.
+//! Also contains `EditorText`, the low-level custom `Element` that shapes text
+//! and paints the cursor, and `EditorView`, a cached `View` wrapper that
+//! automatically pairs an `Editor` entity with its `EditorText` element.
 
+use std::hash::Hash;
 use std::ops::Range;
 use std::time::Duration;
 
 use gpui::{
-    App, Bounds, Context, EntityInputHandler, FocusHandle, Focusable, Pixels, Task, UTF16Selection,
-    Window,
+    App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable,
+    Hsla, IntoViewElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Task, TextRun,
+    UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size,
 };
 use unicode_segmentation::*;
 
@@ -254,3 +256,232 @@ impl EntityInputHandler for Editor {
         None
     }
 }
+
+// ---------------------------------------------------------------------------
+// EditorText — custom Element that shapes text & paints the cursor
+// ---------------------------------------------------------------------------
+
+struct EditorText {
+    editor: Entity<Editor>,
+    text_color: Hsla,
+}
+
+struct EditorTextPrepaintState {
+    lines: Vec<ShapedLine>,
+    cursor: Option<PaintQuad>,
+}
+
+impl EditorText {
+    pub fn new(editor: Entity<Editor>, text_color: Hsla) -> Self {
+        Self { editor, text_color }
+    }
+}
+
+impl IntoElement for EditorText {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for EditorText {
+    type RequestLayoutState = ();
+    type PrepaintState = EditorTextPrepaintState;
+
+    fn id(&self) -> Option<gpui::ElementId> {
+        None
+    }
+
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        let line_count = self.editor.read(cx).content.split('\n').count().max(1);
+        let line_height = window.line_height();
+        let mut style = gpui::Style::default();
+        style.size.width = relative(1.).into();
+        style.size.height = (line_height * line_count as f32).into();
+        (window.request_layout(style, [], cx), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::PrepaintState {
+        let editor = self.editor.read(cx);
+        let content = &editor.content;
+        let cursor_offset = editor.cursor;
+        let cursor_visible = editor.cursor_visible;
+        let is_focused = editor.focus_handle.is_focused(window);
+
+        let style = window.text_style();
+        let font_size = style.font_size.to_pixels(window.rem_size());
+        let line_height = window.line_height();
+
+        let is_placeholder = content.is_empty();
+
+        let shaped_lines: Vec<ShapedLine> = if is_placeholder {
+            let placeholder: SharedString = "Type here...".into();
+            let run = TextRun {
+                len: placeholder.len(),
+                font: style.font(),
+                color: hsla(0., 0., 0.5, 0.5),
+                background_color: None,
+                underline: None,
+                strikethrough: None,
+            };
+            vec![
+                window
+                    .text_system()
+                    .shape_line(placeholder, font_size, &[run], None),
+            ]
+        } else {
+            content
+                .split('\n')
+                .map(|line_str| {
+                    let text: SharedString = SharedString::from(line_str.to_string());
+                    let run = TextRun {
+                        len: text.len(),
+                        font: style.font(),
+                        color: self.text_color,
+                        background_color: None,
+                        underline: None,
+                        strikethrough: None,
+                    };
+                    window
+                        .text_system()
+                        .shape_line(text, font_size, &[run], None)
+                })
+                .collect()
+        };
+
+        let cursor = if is_focused && cursor_visible && !is_placeholder {
+            let (cursor_line, offset_in_line) = cursor_line_and_offset(content, cursor_offset);
+            let cursor_line = cursor_line.min(shaped_lines.len().saturating_sub(1));
+            let cursor_x = shaped_lines[cursor_line].x_for_index(offset_in_line);
+
+            Some(fill(
+                Bounds::new(
+                    point(
+                        bounds.left() + cursor_x,
+                        bounds.top() + line_height * cursor_line as f32,
+                    ),
+                    size(px(1.5), line_height),
+                ),
+                self.text_color,
+            ))
+        } else if is_focused && cursor_visible && is_placeholder {
+            Some(fill(
+                Bounds::new(
+                    point(bounds.left(), bounds.top()),
+                    size(px(1.5), line_height),
+                ),
+                self.text_color,
+            ))
+        } else {
+            None
+        };
+
+        EditorTextPrepaintState {
+            lines: shaped_lines,
+            cursor,
+        }
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let focus_handle = self.editor.read(cx).focus_handle.clone();
+
+        window.handle_input(
+            &focus_handle,
+            ElementInputHandler::new(bounds, self.editor.clone()),
+            cx,
+        );
+
+        let line_height = window.line_height();
+        for (i, line) in prepaint.lines.iter().enumerate() {
+            let origin = point(bounds.left(), bounds.top() + line_height * i as f32);
+            line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx)
+                .unwrap();
+        }
+
+        if let Some(cursor) = prepaint.cursor.take() {
+            window.paint_quad(cursor);
+        }
+    }
+}
+
+fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) {
+    let mut line_index = 0;
+    let mut line_start = 0;
+    for (i, ch) in content.char_indices() {
+        if i >= cursor {
+            break;
+        }
+        if ch == '\n' {
+            line_index += 1;
+            line_start = i + 1;
+        }
+    }
+    (line_index, cursor - line_start)
+}
+
+// ---------------------------------------------------------------------------
+// EditorView — a cached View that pairs an Editor entity with EditorText
+// ---------------------------------------------------------------------------
+
+/// A simple cached view that renders an `Editor` entity via the `EditorText`
+/// custom element. Use this when you want a bare editor display with automatic
+/// caching and no extra chrome.
+#[derive(IntoViewElement, Hash)]
+pub struct EditorView {
+    editor: Entity<Editor>,
+    text_color: Hsla,
+}
+
+impl EditorView {
+    pub fn new(editor: Entity<Editor>) -> Self {
+        Self {
+            editor,
+            text_color: hsla(0., 0., 0.1, 1.),
+        }
+    }
+
+    pub fn text_color(mut self, color: Hsla) -> Self {
+        self.text_color = color;
+        self
+    }
+}
+
+impl gpui::View for EditorView {
+    type State = Editor;
+
+    fn entity(&self) -> &Entity<Editor> {
+        &self.editor
+    }
+
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        EditorText::new(self.editor, self.text_color)
+    }
+}

crates/gpui/examples/text_views/editor_text.rs 🔗

@@ -1,200 +0,0 @@
-//! The `EditorText` element — turns `Editor` state into pixels.
-//!
-//! This is a custom `Element` implementation that handles the low-level work:
-//! - Shapes text into `ShapedLine`s during prepaint (one per hard line break)
-//! - Computes the cursor quad position across multiple lines
-//! - Paints the shaped text and cursor during paint
-//! - Calls `window.handle_input()` during paint to wire platform text input
-
-use gpui::{
-    App, Bounds, ElementInputHandler, Entity, Hsla, LayoutId, PaintQuad, Pixels, ShapedLine,
-    SharedString, TextRun, Window, fill, hsla, point, prelude::*, px, relative, size,
-};
-
-use crate::editor::Editor;
-
-pub struct EditorText {
-    editor: Entity<Editor>,
-    text_color: Hsla,
-}
-
-pub struct PrepaintState {
-    lines: Vec<ShapedLine>,
-    cursor: Option<PaintQuad>,
-}
-
-impl EditorText {
-    pub fn new(editor: Entity<Editor>, text_color: Hsla) -> Self {
-        Self { editor, text_color }
-    }
-}
-
-impl IntoElement for EditorText {
-    type Element = Self;
-
-    fn into_element(self) -> Self::Element {
-        self
-    }
-}
-
-impl Element for EditorText {
-    type RequestLayoutState = ();
-    type PrepaintState = PrepaintState;
-
-    fn id(&self) -> Option<gpui::ElementId> {
-        None
-    }
-
-    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
-        None
-    }
-
-    fn request_layout(
-        &mut self,
-        _id: Option<&gpui::GlobalElementId>,
-        _inspector_id: Option<&gpui::InspectorElementId>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> (LayoutId, Self::RequestLayoutState) {
-        let line_count = self.editor.read(cx).content.split('\n').count().max(1);
-        let line_height = window.line_height();
-        let mut style = gpui::Style::default();
-        style.size.width = relative(1.).into();
-        style.size.height = (line_height * line_count as f32).into();
-        (window.request_layout(style, [], cx), ())
-    }
-
-    fn prepaint(
-        &mut self,
-        _id: Option<&gpui::GlobalElementId>,
-        _inspector_id: Option<&gpui::InspectorElementId>,
-        bounds: Bounds<Pixels>,
-        _request_layout: &mut Self::RequestLayoutState,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Self::PrepaintState {
-        let editor = self.editor.read(cx);
-        let content = &editor.content;
-        let cursor_offset = editor.cursor;
-        let cursor_visible = editor.cursor_visible;
-        let is_focused = editor.focus_handle.is_focused(window);
-
-        let style = window.text_style();
-        let font_size = style.font_size.to_pixels(window.rem_size());
-        let line_height = window.line_height();
-
-        let is_placeholder = content.is_empty();
-
-        let shaped_lines: Vec<ShapedLine> = if is_placeholder {
-            let placeholder: SharedString = "Type here...".into();
-            let run = TextRun {
-                len: placeholder.len(),
-                font: style.font(),
-                color: hsla(0., 0., 0.5, 0.5),
-                background_color: None,
-                underline: None,
-                strikethrough: None,
-            };
-            vec![
-                window
-                    .text_system()
-                    .shape_line(placeholder, font_size, &[run], None),
-            ]
-        } else {
-            content
-                .split('\n')
-                .map(|line_str| {
-                    let text: SharedString = SharedString::from(line_str.to_string());
-                    let run = TextRun {
-                        len: text.len(),
-                        font: style.font(),
-                        color: self.text_color,
-                        background_color: None,
-                        underline: None,
-                        strikethrough: None,
-                    };
-                    window
-                        .text_system()
-                        .shape_line(text, font_size, &[run], None)
-                })
-                .collect()
-        };
-
-        let cursor = if is_focused && cursor_visible && !is_placeholder {
-            let (cursor_line, offset_in_line) = cursor_line_and_offset(content, cursor_offset);
-            let cursor_line = cursor_line.min(shaped_lines.len().saturating_sub(1));
-            let cursor_x = shaped_lines[cursor_line].x_for_index(offset_in_line);
-
-            Some(fill(
-                Bounds::new(
-                    point(
-                        bounds.left() + cursor_x,
-                        bounds.top() + line_height * cursor_line as f32,
-                    ),
-                    size(px(1.5), line_height),
-                ),
-                self.text_color,
-            ))
-        } else if is_focused && cursor_visible && is_placeholder {
-            Some(fill(
-                Bounds::new(
-                    point(bounds.left(), bounds.top()),
-                    size(px(1.5), line_height),
-                ),
-                self.text_color,
-            ))
-        } else {
-            None
-        };
-
-        PrepaintState {
-            lines: shaped_lines,
-            cursor,
-        }
-    }
-
-    fn paint(
-        &mut self,
-        _id: Option<&gpui::GlobalElementId>,
-        _inspector_id: Option<&gpui::InspectorElementId>,
-        bounds: Bounds<Pixels>,
-        _request_layout: &mut Self::RequestLayoutState,
-        prepaint: &mut Self::PrepaintState,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        let focus_handle = self.editor.read(cx).focus_handle.clone();
-
-        window.handle_input(
-            &focus_handle,
-            ElementInputHandler::new(bounds, self.editor.clone()),
-            cx,
-        );
-
-        let line_height = window.line_height();
-        for (i, line) in prepaint.lines.iter().enumerate() {
-            let origin = point(bounds.left(), bounds.top() + line_height * i as f32);
-            line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx)
-                .unwrap();
-        }
-
-        if let Some(cursor) = prepaint.cursor.take() {
-            window.paint_quad(cursor);
-        }
-    }
-}
-
-fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) {
-    let mut line_index = 0;
-    let mut line_start = 0;
-    for (i, ch) in content.char_indices() {
-        if i >= cursor {
-            break;
-        }
-        if ch == '\n' {
-            line_index += 1;
-            line_start = i + 1;
-        }
-    }
-    (line_index, cursor - line_start)
-}

crates/gpui/examples/text_views/input.rs 🔗

@@ -8,20 +8,20 @@
 use std::time::Duration;
 
 use gpui::{
-    Animation, AnimationExt as _, App, BoxShadow, CursorStyle, Entity, Hsla, Pixels, SharedString,
-    StyleRefinement, ViewElement, Window, bounce, div, ease_in_out, hsla, point, prelude::*, px,
-    white,
+    Animation, AnimationExt as _, App, BoxShadow, CursorStyle, Entity, Hsla, IntoViewElement,
+    Pixels, SharedString, StyleRefinement, Window, bounce, div, ease_in_out, hsla, point,
+    prelude::*, px, white,
 };
 
 use crate::editor::Editor;
-use crate::editor_text::EditorText;
+use crate::editor::EditorView;
 use crate::{Backspace, Delete, End, Enter, Home, Left, Right};
 
 struct FlashState {
     count: usize,
 }
 
-#[derive(Hash)]
+#[derive(Hash, IntoViewElement)]
 pub struct Input {
     editor: Entity<Editor>,
     width: Option<Pixels>,
@@ -55,6 +55,15 @@ impl gpui::View for Input {
         &self.editor
     }
 
+    fn style(&self) -> Option<StyleRefinement> {
+        let mut style = StyleRefinement::default();
+        if let Some(w) = self.width {
+            style.size.width = Some(w.into());
+        }
+        style.size.height = Some(px(36.).into());
+        Some(style)
+    }
+
     fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let flash_state = window.use_state(cx, |_window, _cx| FlashState { count: 0 });
         let count = flash_state.read(cx).count;
@@ -145,7 +154,7 @@ impl gpui::View for Input {
             .line_height(px(20.))
             .text_size(px(14.))
             .text_color(text_color)
-            .child(EditorText::new(editor, text_color));
+            .child(EditorView::new(editor).text_color(text_color));
 
         if count > 0 {
             base.with_animation(
@@ -164,16 +173,3 @@ impl gpui::View for Input {
         }
     }
 }
-
-impl IntoElement for Input {
-    type Element = ViewElement<Self>;
-
-    fn into_element(self) -> Self::Element {
-        let mut style = StyleRefinement::default();
-        if let Some(w) = self.width {
-            style.size.width = Some(w.into());
-        }
-        style.size.height = Some(px(36.).into());
-        ViewElement::new(self).cached(style)
-    }
-}

crates/gpui/examples/text_views/text_area.rs 🔗

@@ -5,15 +5,15 @@
 //! components with different props and layouts.
 
 use gpui::{
-    App, BoxShadow, CursorStyle, Entity, Hsla, StyleRefinement, ViewElement, Window, div, hsla,
+    App, BoxShadow, CursorStyle, Entity, Hsla, IntoViewElement, StyleRefinement, Window, div, hsla,
     point, prelude::*, px, white,
 };
 
 use crate::editor::Editor;
-use crate::editor_text::EditorText;
+use crate::editor::EditorView;
 use crate::{Backspace, Delete, End, Enter, Home, Left, Right};
 
-#[derive(Hash)]
+#[derive(Hash, IntoViewElement)]
 pub struct TextArea {
     editor: Entity<Editor>,
     rows: usize,
@@ -42,6 +42,15 @@ impl gpui::View for TextArea {
         &self.editor
     }
 
+    fn style(&self) -> Option<StyleRefinement> {
+        let row_height = px(20.);
+        let box_height = row_height * self.rows as f32 + px(16.);
+        let mut style = StyleRefinement::default();
+        style.size.width = Some(px(400.).into());
+        style.size.height = Some(box_height.into());
+        Some(style)
+    }
+
     fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let focus_handle = self.editor.read(cx).focus_handle.clone();
         let is_focused = focus_handle.is_focused(window);
@@ -120,19 +129,6 @@ impl gpui::View for TextArea {
             .line_height(row_height)
             .text_size(px(14.))
             .text_color(text_color)
-            .child(EditorText::new(editor, text_color))
-    }
-}
-
-impl IntoElement for TextArea {
-    type Element = ViewElement<Self>;
-
-    fn into_element(self) -> Self::Element {
-        let row_height = px(20.);
-        let box_height = row_height * self.rows as f32 + px(16.);
-        let mut style = StyleRefinement::default();
-        style.size.width = Some(px(400.).into());
-        style.size.height = Some(box_height.into());
-        ViewElement::new(self).cached(style)
+            .child(EditorView::new(editor).text_color(text_color))
     }
 }

crates/gpui/src/gpui.rs 🔗

@@ -90,7 +90,8 @@ pub use executor::*;
 pub use geometry::*;
 pub use global::*;
 pub use gpui_macros::{
-    AppContext, IntoElement, Render, VisualContext, property_test, register_action, test,
+    AppContext, IntoElement, IntoViewElement, Render, VisualContext, property_test,
+    register_action, test,
 };
 pub use gpui_util::arc_cow::ArcCow;
 pub use http_client;

crates/gpui/src/view.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId,
     Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex,
-    Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity,
+    Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity, relative,
 };
 use crate::{Empty, Window};
 use anyhow::Result;
@@ -372,6 +372,17 @@ pub trait View: 'static + Sized + Hash {
     /// 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;
+
+    /// Returns the style to use for caching this view.
+    /// When `Some`, the view element will be cached using the given style for its outer layout.
+    /// The default returns a full-size style refinement (`width: 100%, height: 100%`).
+    /// Return `None` to disable caching.
+    fn style(&self) -> Option<StyleRefinement> {
+        let mut style = StyleRefinement::default();
+        style.size.width = Some(relative(1.).into());
+        style.size.height = Some(relative(1.).into());
+        Some(style)
+    }
 }
 
 /// An element that wraps a [`View`], creating a reactive boundary in the element tree.

crates/gpui_macros/src/derive_into_view_element.rs 🔗

@@ -0,0 +1,28 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{DeriveInput, parse_macro_input};
+
+pub fn derive_into_view_element(input: TokenStream) -> TokenStream {
+    let ast = parse_macro_input!(input as DeriveInput);
+    let type_name = &ast.ident;
+    let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
+
+    let r#gen = quote! {
+        impl #impl_generics gpui::IntoElement for #type_name #type_generics
+        #where_clause
+        {
+            type Element = gpui::ViewElement<Self>;
+
+            fn into_element(self) -> Self::Element {
+                let style = gpui::View::style(&self);
+                let element = gpui::ViewElement::new(self);
+                match style {
+                    Some(s) => element.cached(s),
+                    None => element,
+                }
+            }
+        }
+    };
+
+    r#gen.into()
+}

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -1,6 +1,7 @@
 mod derive_action;
 mod derive_app_context;
 mod derive_into_element;
+mod derive_into_view_element;
 mod derive_render;
 mod derive_visual_context;
 mod property_test;
@@ -35,6 +36,14 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream {
     derive_into_element::derive_into_element(input)
 }
 
+/// #[derive(IntoViewElement)] generates an `IntoElement` implementation for types
+/// that implement the `View` trait. It creates a `ViewElement` wrapper and
+/// automatically enables caching based on the `View::style()` method.
+#[proc_macro_derive(IntoViewElement)]
+pub fn derive_into_view_element(input: TokenStream) -> TokenStream {
+    derive_into_view_element::derive_into_view_element(input)
+}
+
 #[proc_macro_derive(Render)]
 #[doc(hidden)]
 pub fn derive_render(input: TokenStream) -> TokenStream {