Detailed changes
@@ -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 }
@@ -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"
@@ -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
+ }
+}
@@ -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);
+ });
+ }
+}
@@ -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)
+}
@@ -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)
+ }
+}
@@ -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();
+}
@@ -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)
+ }
+}
@@ -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"]