Vendor gpui-pre 0.3.8 unmodified (crates.io source)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-11 00:25:10 -04:00
1 parent ba5c0798cb
commit 48a66ca6c1
162 files changed
+104826 -2

No files matched your search

+549
View File
@@ -0,0 +1,549 @@
//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard
//! handling, and the specialized text-shaping renderer. The *text itself* lives
//! in a shared `Entity<String>` it's handed at construction, so the value is
//! readable/writable from outside while the editing machinery stays in here.
//!
//! This is the piece that proves the point: a text input is genuinely
//! complicated, and `View` lets all of that complexity live in one entity that
//! anything can embed.
use std::ops::Range;
use std::time::Duration;
use gpui::{
App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable,
InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task,
TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size,
};
use unicode_segmentation::*;
use crate::{Backspace, Delete, End, Home, Left, Right};
pub struct Editor {
pub value: Entity<String>,
pub focus_handle: FocusHandle,
pub cursor: usize,
pub cursor_visible: bool,
_blink_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
impl Editor {
/// An editor that owns its own string internally, seeded with `text`.
/// Nothing to allocate or wire up at the call site.
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let value = cx.new(|_| text.into());
Self::over(value, window, cx)
}
/// An editor over a string *you* own, so the value is shared in and out.
pub fn over(value: Entity<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| {
this.start_blink(cx);
});
let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| {
this.stop_blink(cx);
});
// The value is shared: anything can write it while we hold a cursor into
// it. Observe it so external writes (a) clamp the cursor back onto a char
// boundary before the next IME round-trip can slice out of bounds, and
// (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache
// is keyed on *our* notify, not the value's.
let value_sub = cx.observe(&value, |this, value, cx| {
let content = value.read(cx);
let mut cursor = this.cursor.min(content.len());
while cursor > 0 && !content.is_char_boundary(cursor) {
cursor -= 1;
}
this.cursor = cursor;
cx.notify();
});
Self {
value,
focus_handle,
cursor: 0,
cursor_visible: false,
_blink_task: Task::ready(()),
_subscriptions: vec![focus_sub, blur_sub, value_sub],
}
}
/// The current text. Read this from anywhere to get the value out.
pub fn text(&self, cx: &App) -> String {
self.value.read(cx).clone()
}
fn start_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = true;
self._blink_task = Self::spawn_blink_task(cx);
}
fn stop_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = false;
self._blink_task = Task::ready(());
cx.notify();
}
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;
}
}
})
}
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>) {
let content = self.text(cx);
if self.cursor > 0 {
self.cursor = previous_boundary(&content, self.cursor);
}
self.reset_blink(cx);
cx.notify();
}
pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor < content.len() {
self.cursor = next_boundary(&content, 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.text(cx).len();
self.reset_blink(cx);
cx.notify();
}
pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor > 0 {
let prev = previous_boundary(&content, self.cursor);
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.drain(prev..cursor);
cx.notify();
});
self.cursor = prev;
}
self.reset_blink(cx);
cx.notify();
}
pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor < content.len() {
let next = next_boundary(&content, self.cursor);
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.drain(cursor..next);
cx.notify();
});
}
self.reset_blink(cx);
cx.notify();
}
pub fn insert_newline(&mut self, cx: &mut Context<Self>) {
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.insert(cursor, '\n');
cx.notify();
});
self.cursor += 1;
self.reset_blink(cx);
cx.notify();
}
}
fn previous_boundary(content: &str, offset: usize) -> usize {
content
.grapheme_indices(true)
.rev()
.find_map(|(idx, _)| (idx < offset).then_some(idx))
.unwrap_or(0)
}
fn next_boundary(content: &str, offset: usize) -> usize {
content
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(content.len())
}
fn offset_from_utf16(content: &str, offset: usize) -> usize {
let mut utf8_offset = 0;
let mut utf16_count = 0;
for ch in content.chars() {
if utf16_count >= offset {
break;
}
utf16_count += ch.len_utf16();
utf8_offset += ch.len_utf8();
}
utf8_offset
}
fn offset_to_utf16(content: &str, offset: usize) -> usize {
let mut utf16_offset = 0;
let mut utf8_count = 0;
for ch in content.chars() {
if utf8_count >= offset {
break;
}
utf8_count += ch.len_utf8();
utf16_offset += ch.len_utf16();
}
utf16_offset
}
fn range_to_utf16(content: &str, range: &Range<usize>) -> Range<usize> {
offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end)
}
fn range_from_utf16(content: &str, range_utf16: &Range<usize>) -> Range<usize> {
offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, 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 content = self.text(cx);
let range = range_from_utf16(&content, &range_utf16);
actual_range.replace(range_to_utf16(&content, &range));
Some(content[range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
let content = self.text(cx);
let utf16_cursor = offset_to_utf16(&content, 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 content = self.text(cx);
let range = range_utf16
.as_ref()
.map(|r| range_from_utf16(&content, r))
.unwrap_or(self.cursor..self.cursor);
let new_content = content[..range.start].to_owned() + new_text + &content[range.end..];
self.cursor = range.start + new_text.len();
self.value.update(cx, |s, cx| {
*s = new_content;
cx.notify();
});
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
}
}
impl gpui::Render for Editor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Editor>) -> impl IntoElement {
EditorText {
editor: cx.entity(),
}
}
}
// ---------------------------------------------------------------------------
// EditorText — the specialized renderer: shapes the text and paints the cursor.
// ---------------------------------------------------------------------------
struct EditorText {
editor: Entity<Editor>,
}
struct EditorTextPrepaint {
lines: Vec<ShapedLine>,
cursor: Option<PaintQuad>,
}
impl IntoElement for EditorText {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for EditorText {
type RequestLayoutState = ();
type PrepaintState = EditorTextPrepaint;
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 editor = self.editor.read(cx);
let content = editor.value.read(cx);
let line_count = 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.value.read(cx).clone();
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 text_color = style.color;
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = window.line_height();
let is_placeholder = content.is_empty();
let 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: 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(lines.len().saturating_sub(1));
let cursor_x = 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),
),
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),
),
text_color,
))
} else {
None
};
EditorTextPrepaint { 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)
}
pub fn standard_actions<E: InteractiveElement>(editor: Entity<Editor>) -> impl FnOnce(E) -> E {
move |element| {
element
.on_action({
let editor = editor.clone();
move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Backspace, window, cx| {
editor.update(cx, |e, cx| e.backspace(a, window, cx))
}
})
.on_action(move |a: &Delete, window, cx| {
editor.update(cx, |e, cx| e.delete(a, window, cx))
})
}
}
+121
View File
@@ -0,0 +1,121 @@
//! `Input` — a single-line text input. The shaping layer over `Editor`.
//!
//! Construct it two ways, depending on how much state you want to own:
//! * `Input::new(value: Entity<String>)` — you hold just the string; the input
//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden.
//! * `Input::editor(editor: Entity<Editor>)` — you hold the editor; cursor/selection
//! are now yours to read and drive too.
//!
//! Either way the chrome is identical. Because the string (or editor) is the
//! input's *identity*, the internal `use_state(Editor)` is collision-safe across
//! any number of inputs.
use gpui::{
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement,
Window, div, hsla, point, prelude::*, px, white,
};
use crate::example_editor::{Editor, standard_actions};
enum Source {
Value(Entity<String>),
Editor(Entity<Editor>),
}
#[derive(IntoElement)]
pub struct Input {
source: Source,
width: Option<Pixels>,
color: Option<Hsla>,
}
impl Input {
/// Backed by a bare string; the editor is allocated internally.
pub fn new(value: Entity<String>) -> Self {
Self {
source: Source::Value(value),
width: None,
color: None,
}
}
/// Backed by an editor you own (so you can read/drive its cursor).
pub fn editor(editor: Entity<Editor>) -> Self {
Self {
source: Source::Editor(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 {
fn entity_id(&self) -> Option<EntityId> {
Some(match &self.source {
Source::Value(value) => value.entity_id(),
Source::Editor(editor) => editor.entity_id(),
})
}
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
// Get the editor: use the one we were handed, or allocate it under our
// own (string-derived) identity so it persists and never collides.
let editor = match self.source {
Source::Value(value) => {
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
}
Source::Editor(editor) => editor,
};
let focus_handle = 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 border = if is_focused {
hsla(220. / 360., 0.8, 0.5, 1.)
} else {
hsla(0., 0., 0.75, 1.)
};
div()
.id("input")
.key_context("TextInput")
.track_focus(&focus_handle)
.cursor(CursorStyle::IBeam)
.map(standard_actions(editor.clone()))
.w(box_width)
.h(px(36.))
.px(px(8.))
.bg(white())
.border_1()
.border_color(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.),
inset: false,
}])
})
.rounded(px(4.))
.overflow_hidden()
.flex()
.items_center()
.line_height(px(20.))
.text_size(px(14.))
.text_color(text_color)
.child(editor.cached(StyleRefinement::default().size_full()))
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Tests for the input composition. Require the `test-support` feature:
//!
//! ```sh
//! cargo test -p gpui --example view_example --features test-support
//! ```
#[cfg(test)]
mod tests {
use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*};
use crate::example_editor::Editor;
use crate::example_input::Input;
use crate::{Backspace, Delete, End, Home, Left, Right};
/// Two inputs, each backed by an editor we own (so the test can focus and
/// read them). Proves data flows through the shared `String` and that
/// sibling inputs stay isolated.
struct Harness {
a: Entity<Editor>,
b: Entity<Editor>,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
gpui::div()
.child(Input::editor(self.a.clone()))
.child(Input::editor(self.b.clone()))
}
}
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),
]);
});
}
fn setup(
cx: &mut TestAppContext,
) -> (
Entity<Editor>,
Entity<String>,
Entity<String>,
&mut gpui::VisualTestContext,
) {
bind_keys(cx);
let (harness, cx) = cx.add_window_view(|window, cx| {
let a_value = cx.new(|_| String::new());
let b_value = cx.new(|_| String::new());
let a = cx.new(|cx| Editor::over(a_value, window, cx));
let b = cx.new(|cx| Editor::over(b_value, window, cx));
Harness { a, b }
});
let a = cx.read_entity(&harness, |h, _| h.a.clone());
let b = cx.read_entity(&harness, |h, _| h.b.clone());
let a_value = cx.read_entity(&a, |e, _| e.value.clone());
let b_value = cx.read_entity(&b, |e, _| e.value.clone());
// Focus the first input's editor.
cx.update(|window, cx| {
let focus_handle = a.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
});
(a, a_value, b_value, cx)
}
#[gpui::test]
fn typing_updates_the_shared_string(cx: &mut TestAppContext) {
let (editor, a_value, _b_value, cx) = setup(cx);
cx.simulate_input("hello");
cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello"));
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
}
#[gpui::test]
fn sibling_inputs_are_isolated(cx: &mut TestAppContext) {
let (_editor, a_value, b_value, cx) = setup(cx);
cx.simulate_input("x");
cx.read_entity(&a_value, |value, _| assert_eq!(value, "x"));
cx.read_entity(&b_value, |value, _| {
assert_eq!(value, "", "typing in input A must not touch input B")
});
}
#[gpui::test]
fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) {
let (editor, a_value, _b_value, cx) = setup(cx);
cx.simulate_input("hello");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
// Write the shared value from outside the editor. The old cursor (5)
// now points into the middle of a multi-byte character; the editor's
// observation must clamp it back onto a boundary.
cx.update(|_, cx| {
a_value.update(cx, |value, cx| {
*value = "日本".to_string();
cx.notify();
})
});
cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本"));
cx.read_entity(&editor, |editor, _| {
assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary");
});
}
#[gpui::test]
fn arrows_move_the_cursor(cx: &mut TestAppContext) {
let (editor, _a_value, _b_value, cx) = setup(cx);
cx.simulate_input("abc");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3));
cx.simulate_keystrokes("left left");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1));
}
}
@@ -0,0 +1,118 @@
//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome,
//! and `Enter` inserts a newline instead of being ignored. Constructible from a
//! string or an editor, exactly like [`Input`](crate::example_input::Input).
use gpui::{
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div,
hsla, point, prelude::*, px, white,
};
use crate::Enter;
use crate::example_editor::{Editor, standard_actions};
enum Source {
Value(Entity<String>),
Editor(Entity<Editor>),
}
#[derive(IntoElement)]
pub struct TextArea {
source: Source,
rows: usize,
color: Option<Hsla>,
}
impl TextArea {
pub fn new(value: Entity<String>, rows: usize) -> Self {
Self {
source: Source::Value(value),
rows,
color: None,
}
}
pub fn editor(editor: Entity<Editor>, rows: usize) -> Self {
Self {
source: Source::Editor(editor),
rows,
color: None,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl gpui::View for TextArea {
fn entity_id(&self) -> Option<EntityId> {
Some(match &self.source {
Source::Value(value) => value.entity_id(),
Source::Editor(editor) => editor.entity_id(),
})
}
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let editor = match self.source {
Source::Value(value) => {
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
}
Source::Editor(editor) => editor,
};
let focus_handle = 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 border = if is_focused {
hsla(220. / 360., 0.8, 0.5, 1.)
} else {
hsla(0., 0., 0.75, 1.)
};
div()
.id("text-area")
.key_context("TextInput")
.track_focus(&focus_handle)
.cursor(CursorStyle::IBeam)
.map(standard_actions(editor.clone()))
// Enter is the one binding that differs from a single-line input.
.on_action({
let editor = editor.clone();
move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx))
})
.w(px(400.))
.h(box_height)
.p(px(8.))
.bg(white())
.border_1()
.border_color(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.),
inset: false,
}])
})
.rounded(px(4.))
.overflow_hidden()
.line_height(row_height)
.text_size(px(14.))
.text_color(text_color)
// The cache style is computed from the `rows` prop: change `rows` and
// the editor's cached bounds change, busting its cache and re-laying
// out the text. (`Input` just uses `size_full()` — nothing to vary.)
.child(
editor.cached(
StyleRefinement::default()
.w_full()
.h(row_height * self.rows as f32),
),
)
}
}
@@ -0,0 +1,179 @@
#![cfg_attr(target_family = "wasm", no_main)]
//! View example — composing a text input from the `View` primitives.
//!
//! The whole point: a text input is deceptively complicated, and `View` makes it
//! easy to compose one. Three pieces, each shown in its own section:
//!
//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a
//! specialized text renderer. All the hard parts live here.
//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out.
//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows
//! the editor internally) OR an `Editor` (so you can read the cursor).
//!
//! Run: `cargo run -p gpui --example view_example`
#[path = "../example_support/fonts.rs"]
mod example_support;
mod example_editor;
mod example_input;
mod example_text_area;
#[cfg(test)]
mod example_tests;
use example_editor::Editor;
use example_input::Input;
use example_text_area::TextArea;
use gpui::{
App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window,
WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size,
};
use gpui_platform::application;
actions!(
view_example,
[Backspace, Delete, Left, Right, Home, End, Enter, Quit]
);
/// A tiny stateless view that reads an editor's cursor and is composed *beside*
/// the thing editing it — two views over one entity, zero wiring.
#[derive(IntoElement)]
struct CursorReadout {
editor: Entity<Editor>,
}
impl CursorReadout {
fn new(editor: Entity<Editor>) -> Self {
Self { editor }
}
}
impl gpui::RenderOnce for CursorReadout {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let cursor = self.editor.read(cx).cursor;
div()
.text_sm()
.text_color(hsla(0., 0., 0.45, 1.))
.child(SharedString::from(format!("cursor @ {cursor}")))
}
}
struct ViewExample;
impl ViewExample {
fn new() -> Self {
Self
}
}
impl Render for ViewExample {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// The data plane: plain strings, allocated at the top by the hook.
let name = window.use_state(cx, |_, _| String::new());
let email = window.use_state(cx, |_, _| String::from("me@example.com"));
let bio = window.use_state(cx, |_, _| String::new());
// Editors that own their own string internally — no extra wiring up top.
let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx));
let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx));
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0xf0f0f0))
.p(px(24.))
.gap(px(24.))
.child(
section("Inputs — from a String (cursor stays internal)")
.child(Input::new(name).width(px(320.)))
.child(
Input::new(email)
.width(px(320.))
.color(hsla(0., 0., 0.3, 1.)),
),
)
.child(
section("Input — from an Editor (read its cursor beside it)").child(
div()
.flex()
.items_center()
.gap(px(12.))
.child(Input::editor(owned.clone()).width(px(320.)))
.child(CursorReadout::new(owned)),
),
)
.child(
section("Text areas — from a String, or from an Editor")
.child(TextArea::new(bio, 3))
.child(
div()
.flex()
.items_start()
.gap(px(12.))
.child(TextArea::editor(notes.clone(), 3).color(hsla(
250. / 360.,
0.7,
0.4,
1.,
)))
.child(CursorReadout::new(notes)),
),
)
}
}
/// A labeled vertical section.
fn section(title: &str) -> Div {
div().flex().flex_col().gap(px(8.)).child(
div()
.text_sm()
.text_color(hsla(0., 0., 0.3, 1.))
.child(SharedString::from(title.to_string())),
)
}
fn run_example() {
application().run(|cx: &mut App| {
if !example_support::load_fonts(cx) {
return;
}
let bounds = Bounds::centered(None, size(px(560.0), px(480.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(|_| ViewExample::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();
}