From f079aeefb8c58f1da90f238f620851703fe40928 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Sun, 15 Mar 2026 15:04:06 -0700 Subject: [PATCH] Refine View example: ExampleInput as View with own state, ExampleTextArea as ComponentView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExampleInput is now a proper View backed by ExampleInputState, which: - Creates and owns its editor entity internally (cx.new in state constructor) - Tracks focus via on_focus/on_blur listeners on its own state entity - Never reads the editor entity during render — only reads its own state - Gets an independent caching boundary from both parent and child editor ExampleTextArea is now a ComponentView (stateless wrapper) where the inner EntityView does the caching. This demonstrates the cheaper pattern for components that don't need their own reactive boundary. The parent (ViewExample) allocates ExampleInputState via use_state, which internally chains to cx.new() for the editor. ExampleInput::new() is a pure constructor taking just the state handle. Design analysis documented in plan.md: - use_state observer is necessary (non-view entities aren't in dispatch tree) - View vs ComponentView vs EntityView taxonomy with clear use cases - Entity reactivity taxonomy (view, component-local, shared-universal, shared-selective) - V2 use_global API concept for theme/settings auto-reactivity --- .../examples/view_example/example_input.rs | 75 +++++++++++++------ .../view_example/example_text_area.rs | 21 +----- crates/gpui/examples/view_example/plan.md | 60 ++++++++++++++- .../view_example/view_example_main.rs | 23 +++--- 4 files changed, 122 insertions(+), 57 deletions(-) diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs index c8b5b9ff59366fd857f2eaeff4aaa10a866ad090..551a4d33af5ee52be5d8c9fc282076242d8747df 100644 --- a/crates/gpui/examples/view_example/example_input.rs +++ b/crates/gpui/examples/view_example/example_input.rs @@ -1,36 +1,64 @@ //! The `ExampleInput` view — a single-line text input component. //! //! Composes `ExampleEditorText` 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()`. +//! and action handlers. Implements the `View` trait backed by its own +//! `ExampleInputState` entity, giving it an independent caching boundary +//! from both its parent and the inner editor. use std::time::Duration; use gpui::{ - Animation, AnimationExt as _, App, BoxShadow, CursorStyle, Entity, Hsla, IntoViewElement, - Pixels, SharedString, StyleRefinement, ViewElement, Window, bounce, div, ease_in_out, hsla, - point, prelude::*, px, white, + Animation, AnimationExt as _, App, BoxShadow, Context, CursorStyle, Entity, FocusHandle, Hsla, + IntoViewElement, Pixels, SharedString, StyleRefinement, Subscription, ViewElement, Window, + bounce, div, ease_in_out, hsla, point, prelude::*, px, white, }; use crate::example_editor::ExampleEditor; use crate::{Backspace, Delete, End, Enter, Home, Left, Right}; -struct FlashState { - count: usize, +pub struct ExampleInputState { + editor: Entity, + focus_handle: FocusHandle, + is_focused: bool, + flash_count: usize, + _subscriptions: Vec, +} + +impl ExampleInputState { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let editor = cx.new(|cx| ExampleEditor::new(cx)); + let focus_handle = editor.read(cx).focus_handle.clone(); + + let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| { + this.is_focused = true; + cx.notify(); + }); + let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| { + this.is_focused = false; + cx.notify(); + }); + + Self { + editor, + focus_handle, + is_focused: false, + flash_count: 0, + _subscriptions: vec![focus_sub, blur_sub], + } + } } #[derive(Hash, IntoViewElement)] pub struct ExampleInput { - editor: Entity, + state: Entity, width: Option, color: Option, } impl ExampleInput { - pub fn new(editor: Entity) -> Self { + pub fn new(state: Entity) -> Self { Self { - editor, + state, width: None, color: None, } @@ -48,10 +76,10 @@ impl ExampleInput { } impl gpui::View for ExampleInput { - type Entity = ExampleEditor; + type Entity = ExampleInputState; - fn entity(&self) -> Option> { - Some(self.editor.clone()) + fn entity(&self) -> Option> { + Some(self.state.clone()) } fn cache_style(&mut self, _window: &mut Window, _cx: &mut App) -> Option { @@ -63,15 +91,15 @@ impl gpui::View for ExampleInput { 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; - - let focus_handle = self.editor.read(cx).focus_handle.clone(); - let is_focused = focus_handle.is_focused(window); + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let input_state = self.state.read(cx); + let count = input_state.flash_count; + let editor = input_state.editor.clone(); + let focus_handle = input_state.focus_handle.clone(); + let is_focused = input_state.is_focused; 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; + let state = self.state; let focused_border = hsla(220. / 360., 0.8, 0.5, 1.); let unfocused_border = hsla(0., 0., 0.75, 1.); @@ -124,10 +152,9 @@ impl gpui::View for ExampleInput { } }) .on_action({ - let flash_state = flash_state; move |_: &Enter, _window, cx| { - flash_state.update(cx, |state, cx| { - state.count += 1; + state.update(cx, |state, cx| { + state.flash_count += 1; cx.notify(); }); } diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs index 8f5536c5680a5734eef1bcaead1e6fb04336f382..9bd53544d24e12c06d8a1f2bbbb1b665cc9fefd2 100644 --- a/crates/gpui/examples/view_example/example_text_area.rs +++ b/crates/gpui/examples/view_example/example_text_area.rs @@ -5,8 +5,8 @@ //! components with different props and layouts. use gpui::{ - App, BoxShadow, CursorStyle, Entity, Hsla, IntoViewElement, StyleRefinement, ViewElement, - Window, div, hsla, point, prelude::*, px, white, + App, BoxShadow, CursorStyle, Entity, Hsla, IntoViewElement, ViewElement, Window, div, hsla, + point, prelude::*, px, white, }; use crate::example_editor::ExampleEditor; @@ -34,22 +34,7 @@ impl ExampleTextArea { } } -impl gpui::View for ExampleTextArea { - type Entity = ExampleEditor; - - fn entity(&self) -> Option> { - Some(self.editor.clone()) - } - - fn cache_style(&mut self, _window: &mut Window, _cx: &mut App) -> Option { - 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) - } - +impl gpui::ComponentView for ExampleTextArea { 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); diff --git a/crates/gpui/examples/view_example/plan.md b/crates/gpui/examples/view_example/plan.md index dfc25039abc1fc3d708fbd3530d27f3d8383a237..d911744079347957b1186eb1450765e1246df51d 100644 --- a/crates/gpui/examples/view_example/plan.md +++ b/crates/gpui/examples/view_example/plan.md @@ -4,10 +4,62 @@ - Introduced `View` trait, `ComponentView` trait, and `ViewElement` struct as a unification of `Component`, `RenderOnce`, `AnyView`, and `Render` - Initialized example of the composition this can achieve with the editor +- Made `ExampleInput` a proper `View` with its own `ExampleInputState` entity — demonstrates independent caching boundaries, focus tracking, and separation of concerns +- Made `ExampleTextArea` a `ComponentView` — demonstrates stateless wrapper pattern where the inner `EntityView` does the caching +- Analyzed `use_state` observer behavior — confirmed it's necessary and correct for component-local state (non-view entities aren't in the dispatch tree, so `mark_view_dirty` can't propagate) ## Next -- Add a render log showing coarse-grained caching (rather than existing spot-caching) -- Add tab index support so that the demo doesn't need mouse movement -- Move focus handles out to the input and textarea, and stop blinking when not focused -- De-fluff LLM generated code (remove excessive comments, simplify implementations, etc.) +- Add a render log showing coarse-grained caching — log `render()` calls on ExampleInput, ExampleTextArea, and ExampleEditor to show that sibling isolation works and FlashState only re-renders chrome +- RootView type — a window root that takes an `Entity` directly. Introduce in a backwards-compatible way alongside `open_window`. Use in this example. +- Move focus handles out to the input and textarea, stop blinking when not focused +- Tab index support so the demo doesn't need mouse movement +- De-fluff LLM generated code + +## Design Decisions + +### View as a separation-of-concerns boundary + +The `ExampleInput` View demonstrates the key principle: a View should own its concerns and delegate everything else. + +- `ExampleInputState` creates the editor internally (`cx.new(|cx| ExampleEditor::new(cx))`) — the parent never sees it +- Focus is tracked via `on_focus`/`on_blur` listeners on the state entity — render never reads the editor +- The editor is passed as a `ViewElement` child and trusted to manage its own rendering (blink, text, cursor) +- `render()` only reads from `self.state` (ExampleInputState), giving it a clean reactive boundary + +The parent just does: +``` +let input_state = window.use_state(cx, |window, cx| ExampleInputState::new(window, cx)); +ExampleInput::new(input_state).width(px(320.)).color(input_color) +``` + +### `use_state` as the allocation pattern for View entities + +`use_state` in the parent creates the View's backing entity. The View's `new()` is a pure constructor that takes the entity handle. The entity's `new()` can internally chain to `cx.new()` to create child entities (like the editor), keeping allocation hierarchical and self-contained. + +### `use_state` observer — keep as-is + +`use_state` creates an observer that notifies the parent view when the state entity changes. This is correct because: + +1. Non-view entities aren't in the dispatch tree, so `mark_view_dirty` can't propagate their notifications upward +2. `use_state` entities are component-local (single owner), so notifying the parent is always the right granularity +3. The alternative (fixing the tracking layer to auto-propagate all entity reads) would be catastrophic for large shared entities like `Project`, where dozens of views read it but only care about specific changes + +### Entity taxonomy for reactivity + +| Entity type | On notification | Mechanism | +|---|---|---| +| View entity (ExampleEditor) | Own ViewElement cache invalidates | `dirty_views` + dispatch tree | +| Component-local (use_state) | Parent view re-renders | `use_state` observer | +| Shared, universally relevant (Theme) | All readers re-render | Explicit `observe` today; `use_global` (v2) | +| Shared, selectively relevant (Project) | Only subscribers re-render | Explicit `subscribe` | + +### View vs ComponentView vs EntityView + +- **View** = has its own entity, gets caching, creates a reactive boundary. The component owns its state and delegates child rendering. Use when the component has state or when caching its render output matters for performance (ExampleInput). +- **ComponentView** = stateless wrapper, no caching, always re-renders when parent does. Use when the component is cheap and the inner child has its own cache (ExampleTextArea). +- **EntityView** = the entity IS the view. The data-owning entity renders itself directly. Use for entities that need full control over their element tree (ExampleEditor). + +### V2: `use_global` for universal reactivity + +A future `cx.use_global::()` API that auto-wires reactive dependencies for globally-shared "value" entities (theme, settings). Would eliminate dozens of boilerplate `observe_global` + `cx.notify()` callbacks throughout the codebase. diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs index 04f616e76e254b4b349abf6e869832b571a8d4c6..f20a6e58d22d926cb07d2dbfc3bf91d1fe3b4440 100644 --- a/crates/gpui/examples/view_example/view_example_main.rs +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -41,7 +41,7 @@ use gpui::{ use gpui_platform::application; use example_editor::ExampleEditor; -use example_input::ExampleInput; +use example_input::{ExampleInput, ExampleInputState}; use example_text_area::ExampleTextArea; actions!( @@ -69,7 +69,7 @@ impl ViewExample { impl Render for ViewExample { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let input_editor = window.use_state(cx, |_window, cx| ExampleEditor::new(cx)); + let input_state = window.use_state(cx, |window, cx| ExampleInputState::new(window, cx)); let textarea_editor = window.use_state(cx, |_window, cx| ExampleEditor::new(cx)); let input_color = self.input_color; let textarea_color = self.textarea_color; @@ -87,13 +87,12 @@ impl Render for ViewExample { .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 ExampleEditorText)"), + div().text_sm().text_color(hsla(0., 0., 0.3, 1.)).child( + "Single-line input (Input — View with own state + cached editor)", + ), ) .child( - ExampleInput::new(input_editor) + ExampleInput::new(input_state) .width(px(320.)) .color(input_color), ), @@ -116,10 +115,12 @@ impl Render for ViewExample { .mt(px(12.)) .text_xs() .text_color(hsla(0., 0., 0.5, 1.)) - .child("• ExampleEditor entity owns state, blink task, EntityInputHandler") - .child("• ExampleEditorText element shapes text, paints cursor, wires handle_input") - .child("• Input / TextArea views compose ExampleEditorText with container styling") - .child("• ViewElement::cached() enables render caching via #[derive(Hash)]") + .child("• ExampleEditor entity owns text, cursor, blink (EntityView)") + .child("• ExampleInput is a View with its own state — caches independently") + .child( + "• ExampleTextArea is a ComponentView — stateless wrapper, editor caches", + ) + .child("• Press Enter in input to flash border (only chrome re-renders)") .child("• Entities created via window.use_state()"), ) }