diff --git a/crates/gpui/examples/text_views/editor.rs b/crates/gpui/examples/text_views/editor.rs index 250853c06c82c3cf5985e1d97940e5a248241932..4c1629a12bc4aa07391cb3df5a3ac78f98273e6e 100644 --- a/crates/gpui/examples/text_views/editor.rs +++ b/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, + text_color: Hsla, +} + +struct EditorTextPrepaintState { + lines: Vec, + cursor: Option, +} + +impl EditorText { + pub fn new(editor: Entity, 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 { + 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, + _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 = 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, + _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, + text_color: Hsla, +} + +impl EditorView { + pub fn new(editor: Entity) -> 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 { + &self.editor + } + + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + EditorText::new(self.editor, self.text_color) + } +} diff --git a/crates/gpui/examples/text_views/editor_text.rs b/crates/gpui/examples/text_views/editor_text.rs deleted file mode 100644 index 07bf51e7e6ffa702c3cf5410c4e1250f0dae6b35..0000000000000000000000000000000000000000 --- a/crates/gpui/examples/text_views/editor_text.rs +++ /dev/null @@ -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, - text_color: Hsla, -} - -pub struct PrepaintState { - lines: Vec, - cursor: Option, -} - -impl EditorText { - pub fn new(editor: Entity, 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 { - 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, - _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 = 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, - _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) -} diff --git a/crates/gpui/examples/text_views/input.rs b/crates/gpui/examples/text_views/input.rs index cc4dc913c7f8853f1852bca9b0c9abc2df2fffde..184cb92eb5187284858b1142bec7844adbd1340b 100644 --- a/crates/gpui/examples/text_views/input.rs +++ b/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, width: Option, @@ -55,6 +55,15 @@ impl gpui::View for Input { &self.editor } + fn style(&self) -> Option { + 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; - - 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) - } -} diff --git a/crates/gpui/examples/text_views/main.rs b/crates/gpui/examples/text_views/main.rs index 2ddd58dda6d129481d0c5708623f8dc081286e2b..e2b78d17d6b51c027ba269cb23b5476692478941 100644 --- a/crates/gpui/examples/text_views/main.rs +++ b/crates/gpui/examples/text_views/main.rs @@ -28,7 +28,6 @@ //! ``` mod editor; -mod editor_text; mod input; mod text_area; diff --git a/crates/gpui/examples/text_views/text_area.rs b/crates/gpui/examples/text_views/text_area.rs index b6377de03c0652e90a7927d5926f1df3b63c5601..91dafee302cac73adcf3b3dd83246d2e24563066 100644 --- a/crates/gpui/examples/text_views/text_area.rs +++ b/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, rows: usize, @@ -42,6 +42,15 @@ impl gpui::View for TextArea { &self.editor } + fn style(&self) -> 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) + } + 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; - - 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)) } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 6d7d801cd42c3639d7892295a660319d21b05dfa..4c644e88c33e21ada9f4ac349714cb2698d18aed 100644 --- a/crates/gpui/src/gpui.rs +++ b/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; diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 8fca738912e57bc68d33e137f96eb05e54042ad3..a04d9f235a04e95ceb390800b7ff81a0042d5638 100644 --- a/crates/gpui/src/view.rs +++ b/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 { + 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. diff --git a/crates/gpui_macros/src/derive_into_view_element.rs b/crates/gpui_macros/src/derive_into_view_element.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e531e4f56fc7530ee7b910361ccf6443f9c0a6a --- /dev/null +++ b/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; + + 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() +} diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index e30c85e6edbee8b5307a5139c00a222e9a83bc55..9e84072a7000ef01e190c2180530beb15d533189 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/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 {