diff --git a/Cargo.toml b/Cargo.toml index e1f5b4ffeb9711e00ae698205b5e9312a49adfe8..8e23c6b9ad1b36a46dc29239a54c7db642577bee 100644 --- a/Cargo.toml +++ b/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 } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 61782fbe50e26a089eefe3c11e70a0016909f6b3..1a65dcd84ad41a8d745ee31d0a483d3e5c6b0b48 100644 --- a/crates/gpui/Cargo.toml +++ b/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" diff --git a/crates/gpui/examples/text_views/editor.rs b/crates/gpui/examples/text_views/editor.rs new file mode 100644 index 0000000000000000000000000000000000000000..250853c06c82c3cf5985e1d97940e5a248241932 --- /dev/null +++ b/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 { + 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) -> 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.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + 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) { + 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.cursor = 0; + self.reset_blink(cx); + cx.notify(); + } + + pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.cursor = self.content.len(); + self.reset_blink(cx); + cx.notify(); + } + + pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + 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) { + 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.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) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + fn range_from_utf16(&self, range_utf16: &Range) -> Range { + 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, + actual_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + 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, + ) -> Option { + 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, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + 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>, + new_text: &str, + _new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text_in_range(range_utf16, new_text, window, cx); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} diff --git a/crates/gpui/examples/text_views/editor_test.rs b/crates/gpui/examples/text_views/editor_test.rs new file mode 100644 index 0000000000000000000000000000000000000000..66e2c1af96650ef5853af1111ae7214cad79d11b --- /dev/null +++ b/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, + } + + impl Render for InputWrapper { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Input::new(self.editor.clone()) + } + } + + struct TextAreaWrapper { + editor: Entity, + } + + impl Render for TextAreaWrapper { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, &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, &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); + }); + } +} diff --git a/crates/gpui/examples/text_views/editor_text.rs b/crates/gpui/examples/text_views/editor_text.rs new file mode 100644 index 0000000000000000000000000000000000000000..07bf51e7e6ffa702c3cf5410c4e1250f0dae6b35 --- /dev/null +++ b/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, + 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 new file mode 100644 index 0000000000000000000000000000000000000000..cc4dc913c7f8853f1852bca9b0c9abc2df2fffde --- /dev/null +++ b/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, + width: Option, + color: Option, +} + +impl Input { + pub fn new(editor: Entity) -> 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 { + &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; + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2ddd58dda6d129481d0c5708623f8dc081286e2b --- /dev/null +++ b/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) -> 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(); +} diff --git a/crates/gpui/examples/text_views/text_area.rs b/crates/gpui/examples/text_views/text_area.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6377de03c0652e90a7927d5926f1df3b63c5601 --- /dev/null +++ b/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, + rows: usize, + color: Option, +} + +impl TextArea { + pub fn new(editor: Entity, 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 { + &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; + + 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) + } +} diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index cfb47b1851b9e792c31fad9aca79b3671095b603..5338db8ba88764fa9ec953db3a06c34880b60486 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/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"]