Use the new view system to implement powerful input state and component examples.

Mikayla Maki created

Change summary

Cargo.toml                                     |   2 
crates/gpui/Cargo.toml                         |   4 
crates/gpui/examples/text_views/editor.rs      | 256 ++++++++++++++++++++
crates/gpui/examples/text_views/editor_test.rs | 211 ++++++++++++++++
crates/gpui/examples/text_views/editor_text.rs | 200 +++++++++++++++
crates/gpui/examples/text_views/input.rs       | 179 +++++++++++++
crates/gpui/examples/text_views/main.rs        | 167 +++++++++++++
crates/gpui/examples/text_views/text_area.rs   | 138 ++++++++++
crates/gpui_platform/Cargo.toml                |   2 
9 files changed, 1,157 insertions(+), 2 deletions(-)

Detailed changes

Cargo.toml 🔗

@@ -332,7 +332,7 @@ gpui = { path = "crates/gpui", default-features = false }
 gpui_linux = { path = "crates/gpui_linux", default-features = false }
 gpui_macos = { path = "crates/gpui_macos", default-features = false }
 gpui_macros = { path = "crates/gpui_macros" }
-gpui_platform = { path = "crates/gpui_platform", default-features = false }
+gpui_platform = { path = "crates/gpui_platform" }
 gpui_web = { path = "crates/gpui_web" }
 gpui_wgpu = { path = "crates/gpui_wgpu" }
 gpui_windows = { path = "crates/gpui_windows", default-features = false }

crates/gpui/Cargo.toml 🔗

@@ -238,3 +238,7 @@ path = "examples/grid_layout.rs"
 [[example]]
 name = "mouse_pressure"
 path = "examples/mouse_pressure.rs"
+
+[[example]]
+name = "text_views"
+path = "examples/text_views/main.rs"

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

@@ -0,0 +1,256 @@
+//! 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.
+
+use std::ops::Range;
+use std::time::Duration;
+
+use gpui::{
+    App, Bounds, Context, EntityInputHandler, FocusHandle, Focusable, Pixels, Task, UTF16Selection,
+    Window,
+};
+use unicode_segmentation::*;
+
+use crate::{Backspace, Delete, End, Home, Left, Right};
+
+pub struct Editor {
+    pub focus_handle: FocusHandle,
+    pub content: String,
+    pub cursor: usize,
+    pub cursor_visible: bool,
+    _blink_task: Task<()>,
+}
+
+impl Editor {
+    pub fn new(cx: &mut Context<Self>) -> Self {
+        let blink_task = Self::spawn_blink_task(cx);
+
+        Self {
+            focus_handle: cx.focus_handle(),
+            content: String::new(),
+            cursor: 0,
+            cursor_visible: true,
+            _blink_task: blink_task,
+        }
+    }
+
+    fn spawn_blink_task(cx: &mut Context<Self>) -> Task<()> {
+        cx.spawn(async move |this, cx| {
+            loop {
+                cx.background_executor()
+                    .timer(Duration::from_millis(500))
+                    .await;
+                let result = this.update(cx, |editor, cx| {
+                    editor.cursor_visible = !editor.cursor_visible;
+                    cx.notify();
+                });
+                if result.is_err() {
+                    break;
+                }
+            }
+        })
+    }
+
+    pub fn reset_blink(&mut self, cx: &mut Context<Self>) {
+        self.cursor_visible = true;
+        self._blink_task = Self::spawn_blink_task(cx);
+    }
+
+    pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
+        if self.cursor > 0 {
+            self.cursor = self.previous_boundary(self.cursor);
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
+        if self.cursor < self.content.len() {
+            self.cursor = self.next_boundary(self.cursor);
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
+        self.cursor = 0;
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
+        self.cursor = self.content.len();
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
+        if self.cursor > 0 {
+            let prev = self.previous_boundary(self.cursor);
+            self.content.drain(prev..self.cursor);
+            self.cursor = prev;
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
+        if self.cursor < self.content.len() {
+            let next = self.next_boundary(self.cursor);
+            self.content.drain(self.cursor..next);
+        }
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    pub fn insert_newline(&mut self, cx: &mut Context<Self>) {
+        self.content.insert(self.cursor, '\n');
+        self.cursor += 1;
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    fn previous_boundary(&self, offset: usize) -> usize {
+        self.content
+            .grapheme_indices(true)
+            .rev()
+            .find_map(|(idx, _)| (idx < offset).then_some(idx))
+            .unwrap_or(0)
+    }
+
+    fn next_boundary(&self, offset: usize) -> usize {
+        self.content
+            .grapheme_indices(true)
+            .find_map(|(idx, _)| (idx > offset).then_some(idx))
+            .unwrap_or(self.content.len())
+    }
+
+    fn offset_from_utf16(&self, offset: usize) -> usize {
+        let mut utf8_offset = 0;
+        let mut utf16_count = 0;
+        for ch in self.content.chars() {
+            if utf16_count >= offset {
+                break;
+            }
+            utf16_count += ch.len_utf16();
+            utf8_offset += ch.len_utf8();
+        }
+        utf8_offset
+    }
+
+    fn offset_to_utf16(&self, offset: usize) -> usize {
+        let mut utf16_offset = 0;
+        let mut utf8_count = 0;
+        for ch in self.content.chars() {
+            if utf8_count >= offset {
+                break;
+            }
+            utf8_count += ch.len_utf8();
+            utf16_offset += ch.len_utf16();
+        }
+        utf16_offset
+    }
+
+    fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
+        self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
+    }
+
+    fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
+        self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
+    }
+}
+
+impl Focusable for Editor {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EntityInputHandler for Editor {
+    fn text_for_range(
+        &mut self,
+        range_utf16: Range<usize>,
+        actual_range: &mut Option<Range<usize>>,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Option<String> {
+        let range = self.range_from_utf16(&range_utf16);
+        actual_range.replace(self.range_to_utf16(&range));
+        Some(self.content[range].to_string())
+    }
+
+    fn selected_text_range(
+        &mut self,
+        _ignore_disabled_input: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Option<UTF16Selection> {
+        let utf16_cursor = self.offset_to_utf16(self.cursor);
+        Some(UTF16Selection {
+            range: utf16_cursor..utf16_cursor,
+            reversed: false,
+        })
+    }
+
+    fn marked_text_range(
+        &self,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Option<Range<usize>> {
+        None
+    }
+
+    fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
+
+    fn replace_text_in_range(
+        &mut self,
+        range_utf16: Option<Range<usize>>,
+        new_text: &str,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let range = range_utf16
+            .as_ref()
+            .map(|r| self.range_from_utf16(r))
+            .unwrap_or(self.cursor..self.cursor);
+
+        self.content =
+            self.content[..range.start].to_owned() + new_text + &self.content[range.end..];
+        self.cursor = range.start + new_text.len();
+        self.reset_blink(cx);
+        cx.notify();
+    }
+
+    fn replace_and_mark_text_in_range(
+        &mut self,
+        range_utf16: Option<Range<usize>>,
+        new_text: &str,
+        _new_selected_range_utf16: Option<Range<usize>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.replace_text_in_range(range_utf16, new_text, window, cx);
+    }
+
+    fn bounds_for_range(
+        &mut self,
+        _range_utf16: Range<usize>,
+        _bounds: Bounds<Pixels>,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Option<Bounds<Pixels>> {
+        None
+    }
+
+    fn character_index_for_point(
+        &mut self,
+        _point: gpui::Point<Pixels>,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Option<usize> {
+        None
+    }
+}

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

@@ -0,0 +1,211 @@
+//! Tests for the `Editor` entity.
+//!
+//! These use GPUI's test infrastructure which requires the `test-support` feature:
+//!
+//! ```sh
+//! cargo test --example text_views -p gpui --features test-support
+//! ```
+
+#[cfg(test)]
+mod tests {
+    use std::time::Duration;
+
+    use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*};
+
+    use crate::editor::Editor;
+    use crate::input::Input;
+    use crate::text_area::TextArea;
+    use crate::{Backspace, Delete, End, Enter, Home, Left, Right};
+
+    struct InputWrapper {
+        editor: Entity<Editor>,
+    }
+
+    impl Render for InputWrapper {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+            Input::new(self.editor.clone())
+        }
+    }
+
+    struct TextAreaWrapper {
+        editor: Entity<Editor>,
+    }
+
+    impl Render for TextAreaWrapper {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+            TextArea::new(self.editor.clone(), 5)
+        }
+    }
+
+    fn bind_keys(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            cx.bind_keys([
+                KeyBinding::new("backspace", Backspace, None),
+                KeyBinding::new("delete", Delete, None),
+                KeyBinding::new("left", Left, None),
+                KeyBinding::new("right", Right, None),
+                KeyBinding::new("home", Home, None),
+                KeyBinding::new("end", End, None),
+                KeyBinding::new("enter", Enter, None),
+            ]);
+        });
+    }
+
+    fn init_input(cx: &mut TestAppContext) -> (Entity<Editor>, &mut gpui::VisualTestContext) {
+        bind_keys(cx);
+
+        let (wrapper, cx) = cx.add_window_view(|_window, cx| {
+            let editor = cx.new(|cx| Editor::new(cx));
+            InputWrapper { editor }
+        });
+
+        let editor = cx.read_entity(&wrapper, |wrapper, _cx| wrapper.editor.clone());
+
+        cx.update(|window, cx| {
+            let focus_handle = editor.read(cx).focus_handle.clone();
+            window.focus(&focus_handle, cx);
+        });
+
+        (editor, cx)
+    }
+
+    fn init_textarea(cx: &mut TestAppContext) -> (Entity<Editor>, &mut gpui::VisualTestContext) {
+        bind_keys(cx);
+
+        let (wrapper, cx) = cx.add_window_view(|_window, cx| {
+            let editor = cx.new(|cx| Editor::new(cx));
+            TextAreaWrapper { editor }
+        });
+
+        let editor = cx.read_entity(&wrapper, |wrapper, _cx| wrapper.editor.clone());
+
+        cx.update(|window, cx| {
+            let focus_handle = editor.read(cx).focus_handle.clone();
+            window.focus(&focus_handle, cx);
+        });
+
+        (editor, cx)
+    }
+
+    #[gpui::test]
+    fn test_typing_and_cursor(cx: &mut TestAppContext) {
+        let (editor, cx) = init_input(cx);
+
+        cx.simulate_input("hello");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "hello");
+            assert_eq!(editor.cursor, 5);
+        });
+
+        cx.simulate_keystrokes("left left");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.cursor, 3);
+        });
+
+        cx.simulate_input(" world");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "hel worldlo");
+            assert_eq!(editor.cursor, 9);
+        });
+    }
+
+    #[gpui::test]
+    fn test_backspace_and_delete(cx: &mut TestAppContext) {
+        let (editor, cx) = init_input(cx);
+
+        cx.simulate_input("abcde");
+
+        cx.simulate_keystrokes("backspace");
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "abcd");
+            assert_eq!(editor.cursor, 4);
+        });
+
+        cx.simulate_keystrokes("home delete");
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "bcd");
+            assert_eq!(editor.cursor, 0);
+        });
+
+        // Boundary no-ops
+        cx.simulate_keystrokes("backspace");
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "bcd");
+        });
+
+        cx.simulate_keystrokes("end delete");
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "bcd");
+        });
+    }
+
+    #[gpui::test]
+    fn test_cursor_blink(cx: &mut TestAppContext) {
+        let (editor, cx) = init_input(cx);
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert!(editor.cursor_visible);
+        });
+
+        cx.background_executor
+            .advance_clock(Duration::from_millis(500));
+        cx.run_until_parked();
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert!(!editor.cursor_visible);
+        });
+
+        // Typing resets the blink.
+        cx.simulate_input("a");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert!(editor.cursor_visible);
+        });
+    }
+
+    #[gpui::test]
+    fn test_enter_does_not_insert_in_input(cx: &mut TestAppContext) {
+        let (editor, cx) = init_input(cx);
+
+        cx.simulate_input("hello");
+        cx.simulate_keystrokes("enter");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(
+                editor.content, "hello",
+                "Enter should not insert text in Input"
+            );
+            assert_eq!(editor.cursor, 5);
+        });
+    }
+
+    #[gpui::test]
+    fn test_enter_inserts_newline_in_textarea(cx: &mut TestAppContext) {
+        let (editor, cx) = init_textarea(cx);
+
+        cx.simulate_input("ab");
+        cx.simulate_keystrokes("enter");
+        cx.simulate_input("cd");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "ab\ncd");
+            assert_eq!(editor.cursor, 5);
+        });
+    }
+
+    #[gpui::test]
+    fn test_enter_at_start_of_textarea(cx: &mut TestAppContext) {
+        let (editor, cx) = init_textarea(cx);
+
+        cx.simulate_keystrokes("enter");
+        cx.simulate_input("hello");
+
+        cx.read_entity(&editor, |editor, _cx| {
+            assert_eq!(editor.content, "\nhello");
+            assert_eq!(editor.cursor, 6);
+        });
+    }
+}

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

@@ -0,0 +1,200 @@
+//! 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 🔗

@@ -0,0 +1,179 @@
+//! The `Input` view — a single-line text input component.
+//!
+//! Composes `EditorText` inside a styled container with focus ring, border,
+//! and action handlers. Implements the `View` trait with `#[derive(Hash)]`
+//! so that prop changes (color, width) automatically invalidate the render
+//! cache via `ViewElement::cached()`.
+
+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,
+};
+
+use crate::editor::Editor;
+use crate::editor_text::EditorText;
+use crate::{Backspace, Delete, End, Enter, Home, Left, Right};
+
+struct FlashState {
+    count: usize,
+}
+
+#[derive(Hash)]
+pub struct Input {
+    editor: Entity<Editor>,
+    width: Option<Pixels>,
+    color: Option<Hsla>,
+}
+
+impl Input {
+    pub fn new(editor: Entity<Editor>) -> Self {
+        Self {
+            editor,
+            width: None,
+            color: None,
+        }
+    }
+
+    pub fn width(mut self, width: Pixels) -> Self {
+        self.width = Some(width);
+        self
+    }
+
+    pub fn color(mut self, color: Hsla) -> Self {
+        self.color = Some(color);
+        self
+    }
+}
+
+impl gpui::View for Input {
+    type State = Editor;
+
+    fn entity(&self) -> &Entity<Editor> {
+        &self.editor
+    }
+
+    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;
+
+        let focus_handle = self.editor.read(cx).focus_handle.clone();
+        let is_focused = focus_handle.is_focused(window);
+        let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
+        let box_width = self.width.unwrap_or(px(300.));
+        let editor = self.editor.clone();
+
+        let focused_border = hsla(220. / 360., 0.8, 0.5, 1.);
+        let unfocused_border = hsla(0., 0., 0.75, 1.);
+        let normal_border = if is_focused {
+            focused_border
+        } else {
+            unfocused_border
+        };
+        let highlight_border = hsla(140. / 360., 0.8, 0.5, 1.);
+
+        let base = div()
+            .id("input")
+            .key_context("TextInput")
+            .track_focus(&focus_handle)
+            .cursor(CursorStyle::IBeam)
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Backspace, _window, cx| {
+                    editor.update(cx, |state, cx| state.backspace(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Delete, _window, cx| {
+                    editor.update(cx, |state, cx| state.delete(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Left, _window, cx| {
+                    editor.update(cx, |state, cx| state.left(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Right, _window, cx| {
+                    editor.update(cx, |state, cx| state.right(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Home, _window, cx| {
+                    editor.update(cx, |state, cx| state.home(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &End, _window, cx| {
+                    editor.update(cx, |state, cx| state.end(action, _window, cx));
+                }
+            })
+            .on_action({
+                let flash_state = flash_state.clone();
+                move |_: &Enter, _window, cx| {
+                    flash_state.update(cx, |state, cx| {
+                        state.count += 1;
+                        cx.notify();
+                    });
+                }
+            })
+            .w(box_width)
+            .h(px(36.))
+            .px(px(8.))
+            .bg(white())
+            .border_1()
+            .border_color(normal_border)
+            .when(is_focused, |this| {
+                this.shadow(vec![BoxShadow {
+                    color: hsla(220. / 360., 0.8, 0.5, 0.3),
+                    offset: point(px(0.), px(0.)),
+                    blur_radius: px(4.),
+                    spread_radius: px(1.),
+                }])
+            })
+            .rounded(px(4.))
+            .overflow_hidden()
+            .flex()
+            .items_center()
+            .line_height(px(20.))
+            .text_size(px(14.))
+            .text_color(text_color)
+            .child(EditorText::new(editor, text_color));
+
+        if count > 0 {
+            base.with_animation(
+                SharedString::from(format!("enter-bounce-{count}")),
+                Animation::new(Duration::from_millis(300)).with_easing(bounce(ease_in_out)),
+                move |this, delta| {
+                    let h = normal_border.h + (highlight_border.h - normal_border.h) * delta;
+                    let s = normal_border.s + (highlight_border.s - normal_border.s) * delta;
+                    let l = normal_border.l + (highlight_border.l - normal_border.l) * delta;
+                    this.border_color(hsla(h, s, l, 1.0))
+                },
+            )
+            .into_any_element()
+        } else {
+            base.into_any_element()
+        }
+    }
+}
+
+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/main.rs 🔗

@@ -0,0 +1,167 @@
+#![cfg_attr(target_family = "wasm", no_main)]
+
+//! **text_views** — an end-to-end GPUI example demonstrating how Entity,
+//! Element, View, and Render compose together to build rich text components.
+//!
+//! ## Architecture
+//!
+//! Each module has a focused job:
+//!
+//! | Module          | Layer   | Job                                                      |
+//! |-----------------|---------|----------------------------------------------------------|
+//! | `editor`        | Entity  | Owns text, cursor, blink task, `EntityInputHandler`      |
+//! | `editor_text`   | Element | Shapes text, paints cursor, wires `handle_input`         |
+//! | `input`         | View    | Single-line input — composes `EditorText` with styling   |
+//! | `text_area`     | View    | Multi-line text area — same entity, different layout      |
+//! | `main` (here)   | Render  | Root view — creates entities with `use_state`, assembles  |
+//!
+//! ## Running
+//!
+//! ```sh
+//! cargo run --example text_views -p gpui
+//! ```
+//!
+//! ## Testing
+//!
+//! ```sh
+//! cargo test --example text_views -p gpui
+//! ```
+
+mod editor;
+mod editor_text;
+mod input;
+mod text_area;
+
+#[cfg(test)]
+mod editor_test;
+
+use gpui::{
+    App, Bounds, Context, Hsla, KeyBinding, Window, WindowBounds, WindowOptions, actions, div,
+    hsla, prelude::*, px, rgb, size,
+};
+use gpui_platform::application;
+
+use editor::Editor;
+use input::Input;
+use text_area::TextArea;
+
+actions!(
+    text_views,
+    [Backspace, Delete, Left, Right, Home, End, Enter, Quit,]
+);
+
+// ---------------------------------------------------------------------------
+// Example — the root view using `Render` and `window.use_state()`
+// ---------------------------------------------------------------------------
+
+struct Example {
+    input_color: Hsla,
+    textarea_color: Hsla,
+}
+
+impl Example {
+    fn new() -> Self {
+        Self {
+            input_color: hsla(0., 0., 0.1, 1.),
+            textarea_color: hsla(250. / 360., 0.7, 0.4, 1.),
+        }
+    }
+}
+
+impl Render for Example {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let input_editor = window.use_state(cx, |_window, cx| Editor::new(cx));
+        let textarea_editor = window.use_state(cx, |_window, cx| Editor::new(cx));
+        let input_color = self.input_color;
+        let textarea_color = self.textarea_color;
+
+        div()
+            .flex()
+            .flex_col()
+            .size_full()
+            .bg(rgb(0xf0f0f0))
+            .p(px(24.))
+            .gap(px(20.))
+            .child(
+                div()
+                    .flex()
+                    .flex_col()
+                    .gap(px(4.))
+                    .child(
+                        div()
+                            .text_sm()
+                            .text_color(hsla(0., 0., 0.3, 1.))
+                            .child("Single-line input (Input — View with cached EditorText)"),
+                    )
+                    .child(Input::new(input_editor).width(px(320.)).color(input_color)),
+            )
+            .child(
+                div()
+                    .flex()
+                    .flex_col()
+                    .gap(px(4.))
+                    .child(div().text_sm().text_color(hsla(0., 0., 0.3, 1.)).child(
+                        "Multi-line text area (TextArea — same entity type, different View)",
+                    ))
+                    .child(TextArea::new(textarea_editor, 5).color(textarea_color)),
+            )
+            .child(
+                div()
+                    .flex()
+                    .flex_col()
+                    .gap(px(2.))
+                    .mt(px(12.))
+                    .text_xs()
+                    .text_color(hsla(0., 0., 0.5, 1.))
+                    .child("• Editor entity owns state, blink task, EntityInputHandler")
+                    .child("• EditorText element shapes text, paints cursor, wires handle_input")
+                    .child("• Input / TextArea views compose EditorText with container styling")
+                    .child("• ViewElement::cached() enables render caching via #[derive(Hash)]")
+                    .child("• Entities created via window.use_state()"),
+            )
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+fn run_example() {
+    application().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
+        cx.bind_keys([
+            KeyBinding::new("backspace", Backspace, None),
+            KeyBinding::new("delete", Delete, None),
+            KeyBinding::new("left", Left, None),
+            KeyBinding::new("right", Right, None),
+            KeyBinding::new("home", Home, None),
+            KeyBinding::new("end", End, None),
+            KeyBinding::new("enter", Enter, None),
+            KeyBinding::new("cmd-q", Quit, None),
+        ]);
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |_, cx| cx.new(|_| Example::new()),
+        )
+        .unwrap();
+
+        cx.on_action(|_: &Quit, cx| cx.quit());
+        cx.activate(true);
+    });
+}
+
+#[cfg(not(target_family = "wasm"))]
+fn main() {
+    run_example();
+}
+
+#[cfg(target_family = "wasm")]
+#[wasm_bindgen::prelude::wasm_bindgen(start)]
+pub fn start() {
+    gpui_platform::web_init();
+    run_example();
+}

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

@@ -0,0 +1,138 @@
+//! The `TextArea` view — a multi-line text area component.
+//!
+//! Same `Editor` entity, different presentation: taller box with configurable
+//! row count. Demonstrates that the same entity type can back different `View`
+//! components with different props and layouts.
+
+use gpui::{
+    App, BoxShadow, CursorStyle, Entity, Hsla, StyleRefinement, ViewElement, Window, div, hsla,
+    point, prelude::*, px, white,
+};
+
+use crate::editor::Editor;
+use crate::editor_text::EditorText;
+use crate::{Backspace, Delete, End, Enter, Home, Left, Right};
+
+#[derive(Hash)]
+pub struct TextArea {
+    editor: Entity<Editor>,
+    rows: usize,
+    color: Option<Hsla>,
+}
+
+impl TextArea {
+    pub fn new(editor: Entity<Editor>, rows: usize) -> Self {
+        Self {
+            editor,
+            rows,
+            color: None,
+        }
+    }
+
+    pub fn color(mut self, color: Hsla) -> Self {
+        self.color = Some(color);
+        self
+    }
+}
+
+impl gpui::View for TextArea {
+    type State = Editor;
+
+    fn entity(&self) -> &Entity<Editor> {
+        &self.editor
+    }
+
+    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);
+        let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
+        let row_height = px(20.);
+        let box_height = row_height * self.rows as f32 + px(16.);
+        let editor = self.editor.clone();
+
+        div()
+            .id("text-area")
+            .key_context("TextInput")
+            .track_focus(&focus_handle)
+            .cursor(CursorStyle::IBeam)
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Backspace, _window, cx| {
+                    editor.update(cx, |state, cx| state.backspace(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Delete, _window, cx| {
+                    editor.update(cx, |state, cx| state.delete(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Left, _window, cx| {
+                    editor.update(cx, |state, cx| state.left(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Right, _window, cx| {
+                    editor.update(cx, |state, cx| state.right(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &Home, _window, cx| {
+                    editor.update(cx, |state, cx| state.home(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |action: &End, _window, cx| {
+                    editor.update(cx, |state, cx| state.end(action, _window, cx));
+                }
+            })
+            .on_action({
+                let editor = editor.clone();
+                move |_: &Enter, _window, cx| {
+                    editor.update(cx, |state, cx| state.insert_newline(cx));
+                }
+            })
+            .w(px(400.))
+            .h(box_height)
+            .p(px(8.))
+            .bg(white())
+            .border_1()
+            .border_color(if is_focused {
+                hsla(220. / 360., 0.8, 0.5, 1.)
+            } else {
+                hsla(0., 0., 0.75, 1.)
+            })
+            .when(is_focused, |this| {
+                this.shadow(vec![BoxShadow {
+                    color: hsla(220. / 360., 0.8, 0.5, 0.3),
+                    offset: point(px(0.), px(0.)),
+                    blur_radius: px(4.),
+                    spread_radius: px(1.),
+                }])
+            })
+            .rounded(px(4.))
+            .overflow_hidden()
+            .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)
+    }
+}

crates/gpui_platform/Cargo.toml 🔗

@@ -12,7 +12,7 @@ workspace = true
 path = "src/gpui_platform.rs"
 
 [features]
-default = []
+default = ["font-kit"]
 font-kit = ["gpui_macos/font-kit"]
 test-support = ["gpui/test-support", "gpui_macos/test-support"]
 screen-capture = ["gpui/screen-capture", "gpui_macos/screen-capture", "gpui_windows/screen-capture", "gpui_linux/screen-capture"]