1use std::collections::BTreeMap;
2use std::ops::Range;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use gpui::{
7 App, Bounds, ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler,
8 Entity, EntityInputHandler, FocusHandle, Focusable, FontFallbacks, FontWeight, GlobalElementId,
9 IntoElement, KeyBinding, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent,
10 MouseUpEvent, PaintQuad, Pixels, Point, Render, ScrollHandle, SharedString, Style as GpuiStyle,
11 TextAlign, TextRun, UTF16Selection, UnderlineStyle, Window, WrappedLine, actions, div, fill,
12 point, prelude::*, px, relative, rgba, size,
13};
14use rhai::{Engine, FuncRegistration, INT, ImmutableString};
15
16use crate::text_edit::TextBuffer;
17use crate::text_input::{NativeTypography, TextInputCallbacks, native_typography};
18use crate::{
19 ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
20 PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
21 PrimitiveValue, Rgba8, UiValue, ValueSchema,
22};
23
24const MAX_TEXTAREA_ROWS: i64 = 1_000;
25const MAX_TEXTAREA_GRAPHEMES: i64 = 1_000_000;
26
27actions!(
28 gpui_rhai_textarea,
29 [
30 Backspace,
31 Delete,
32 Left,
33 Right,
34 Up,
35 Down,
36 SelectLeft,
37 SelectRight,
38 SelectUp,
39 SelectDown,
40 SelectAll,
41 Home,
42 End,
43 Newline,
44 ShowCharacterPalette,
45 Paste,
46 Cut,
47 Copy,
48 Undo,
49 Redo,
50 ]
51);
52
53pub fn init_text_area(cx: &mut App) {
54 cx.bind_keys([
55 KeyBinding::new("backspace", Backspace, Some("GPUIRhaiTextarea")),
56 KeyBinding::new("delete", Delete, Some("GPUIRhaiTextarea")),
57 KeyBinding::new("left", Left, Some("GPUIRhaiTextarea")),
58 KeyBinding::new("right", Right, Some("GPUIRhaiTextarea")),
59 KeyBinding::new("up", Up, Some("GPUIRhaiTextarea")),
60 KeyBinding::new("down", Down, Some("GPUIRhaiTextarea")),
61 KeyBinding::new("shift-left", SelectLeft, Some("GPUIRhaiTextarea")),
62 KeyBinding::new("shift-right", SelectRight, Some("GPUIRhaiTextarea")),
63 KeyBinding::new("shift-up", SelectUp, Some("GPUIRhaiTextarea")),
64 KeyBinding::new("shift-down", SelectDown, Some("GPUIRhaiTextarea")),
65 KeyBinding::new("cmd-a", SelectAll, Some("GPUIRhaiTextarea")),
66 KeyBinding::new("cmd-v", Paste, Some("GPUIRhaiTextarea")),
67 KeyBinding::new("cmd-c", Copy, Some("GPUIRhaiTextarea")),
68 KeyBinding::new("cmd-x", Cut, Some("GPUIRhaiTextarea")),
69 KeyBinding::new("cmd-z", Undo, Some("GPUIRhaiTextarea")),
70 KeyBinding::new("shift-cmd-z", Redo, Some("GPUIRhaiTextarea")),
71 KeyBinding::new("home", Home, Some("GPUIRhaiTextarea")),
72 KeyBinding::new("end", End, Some("GPUIRhaiTextarea")),
73 KeyBinding::new("enter", Newline, Some("GPUIRhaiTextarea")),
74 KeyBinding::new(
75 "ctrl-cmd-space",
76 ShowCharacterPalette,
77 Some("GPUIRhaiTextarea"),
78 ),
79 ]);
80}
81
82pub(crate) fn register_text_area_api(engine: &mut Engine) {
83 FuncRegistration::new("grapheme_count")
84 .in_global_namespace()
85 .register_into_engine(engine, |value: ImmutableString| -> INT {
86 INT::try_from(
87 unicode_segmentation::UnicodeSegmentation::graphemes(value.as_str(), true).count(),
88 )
89 .unwrap_or(INT::MAX)
90 });
91}
92
93#[derive(Clone, Debug)]
94struct TextAreaConfig {
95 placeholder: SharedString,
96 disabled: bool,
97 read_only: bool,
98 min_rows: usize,
99 max_rows: usize,
100 rows: Option<usize>,
101 max_length: Option<usize>,
102 typography: NativeTypography,
103 autofocus: bool,
104 placeholder_color: Rgba8,
105 selection_color: Rgba8,
106 caret_color: Rgba8,
107 scroll_color: Option<Rgba8>,
108}
109
110impl TextAreaConfig {
111 fn viewport_rows(&self, measured: usize) -> usize {
112 self.rows
113 .unwrap_or_else(|| measured.clamp(self.min_rows, self.max_rows))
114 }
115}
116
117pub(crate) struct TextAreaEntity {
118 focus: FocusHandle,
119 buffer: TextBuffer,
120 config: TextAreaConfig,
121 callbacks: TextInputCallbacks,
122 layout: Option<TextAreaLayout>,
123 measured_rows: usize,
124 last_bounds: Option<Bounds<Pixels>>,
125 preferred_x: Option<Pixels>,
126 selecting: bool,
127 scroll: ScrollHandle,
128}
129
130impl TextAreaEntity {
131 fn new(
132 value: &str,
133 config: TextAreaConfig,
134 callbacks: TextInputCallbacks,
135 cx: &mut Context<Self>,
136 ) -> Result<Self, String> {
137 validate_text_area_value(value, &config)?;
138 let mut buffer = TextBuffer::new(value);
139 buffer.set_max_length(config.max_length);
140 Ok(Self {
141 focus: cx.focus_handle().tab_stop(!config.disabled),
142 buffer,
143 measured_rows: config.min_rows,
144 config,
145 callbacks,
146 layout: None,
147 last_bounds: None,
148 preferred_x: None,
149 selecting: false,
150 scroll: ScrollHandle::new(),
151 })
152 }
153
154 fn update_props(
155 &mut self,
156 value: &str,
157 config: TextAreaConfig,
158 callbacks: TextInputCallbacks,
159 cx: &mut Context<Self>,
160 ) -> Result<(), String> {
161 validate_text_area_value(value, &config)?;
162 if self.buffer.marked().is_none() {
163 self.buffer.set_controlled(value);
164 }
165 self.buffer.set_max_length(config.max_length);
166 self.focus = self.focus.clone().tab_stop(!config.disabled);
167 self.config = config;
168 self.callbacks = callbacks;
169 cx.notify();
170 Ok(())
171 }
172
173 fn emit_change(&self, window: &mut Window, cx: &mut Context<Self>) {
174 if let Some(change) = self.callbacks.change.clone() {
175 let value = self.buffer.content.clone();
176 window.defer(cx, move |window, cx| change(value, window, cx));
177 }
178 }
179
180 fn reset_preferred_x(&mut self) {
181 self.preferred_x = None;
182 }
183
184 fn ensure_cursor_visible(&self) {
185 let Some(layout) = &self.layout else {
186 return;
187 };
188 let Some(position) = layout.position_for_index(self.buffer.cursor_offset()) else {
189 return;
190 };
191 let viewport =
192 self.config.typography.line_height * self.config.viewport_rows(self.measured_rows);
193 let mut top = (-self.scroll.offset().y).max(px(0.0));
194 if position.y < top {
195 top = position.y;
196 } else if position.y + self.config.typography.line_height > top + viewport {
197 top = position.y + self.config.typography.line_height - viewport;
198 }
199 self.scroll.set_offset(point(px(0.0), -top));
200 }
201
202 fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
203 let offset = if self.buffer.selected.is_empty() {
204 self.buffer.previous_boundary(self.buffer.cursor_offset())
205 } else {
206 self.buffer.selected.start
207 };
208 self.buffer.move_to(offset);
209 self.reset_preferred_x();
210 self.ensure_cursor_visible();
211 cx.notify();
212 }
213
214 fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
215 let offset = if self.buffer.selected.is_empty() {
216 self.buffer.next_boundary(self.buffer.selected.end)
217 } else {
218 self.buffer.selected.end
219 };
220 self.buffer.move_to(offset);
221 self.reset_preferred_x();
222 self.ensure_cursor_visible();
223 cx.notify();
224 }
225
226 fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
227 let offset = self.buffer.previous_boundary(self.buffer.cursor_offset());
228 self.buffer.select_to(offset);
229 self.reset_preferred_x();
230 self.ensure_cursor_visible();
231 cx.notify();
232 }
233
234 fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
235 let offset = self.buffer.next_boundary(self.buffer.cursor_offset());
236 self.buffer.select_to(offset);
237 self.reset_preferred_x();
238 self.ensure_cursor_visible();
239 cx.notify();
240 }
241
242 fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
243 self.move_vertical(-1, false);
244 cx.notify();
245 }
246
247 fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
248 self.move_vertical(1, false);
249 cx.notify();
250 }
251
252 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
253 self.move_vertical(-1, true);
254 cx.notify();
255 }
256
257 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
258 self.move_vertical(1, true);
259 cx.notify();
260 }
261
262 fn move_vertical(&mut self, step: i32, extend: bool) {
263 let Some(layout) = &self.layout else {
264 return;
265 };
266 let cursor = self.buffer.cursor_offset();
267 let Some(position) = layout.position_for_index(cursor) else {
268 return;
269 };
270 let x = self.preferred_x.unwrap_or(position.x);
271 self.preferred_x = Some(x);
272 let target_y = if step.is_negative() {
273 position.y - self.config.typography.line_height
274 } else {
275 position.y + self.config.typography.line_height
276 };
277 let offset = layout.closest_index_for_position(point(x, target_y));
278 if extend {
279 self.buffer.select_to(offset);
280 } else {
281 self.buffer.move_to(offset);
282 }
283 self.ensure_cursor_visible();
284 }
285
286 fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
287 self.buffer.move_to(0);
288 self.buffer.select_to(self.buffer.content.len());
289 self.reset_preferred_x();
290 self.ensure_cursor_visible();
291 cx.notify();
292 }
293
294 fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
295 self.buffer
296 .move_to(self.buffer.line_start(self.buffer.cursor_offset()));
297 self.reset_preferred_x();
298 self.ensure_cursor_visible();
299 cx.notify();
300 }
301
302 fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
303 self.buffer
304 .move_to(self.buffer.line_end(self.buffer.cursor_offset()));
305 self.reset_preferred_x();
306 self.ensure_cursor_visible();
307 cx.notify();
308 }
309
310 fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
311 if self.config.disabled || self.config.read_only {
312 return;
313 }
314 if self.buffer.selected.is_empty() {
315 let previous = self.buffer.previous_boundary(self.buffer.cursor_offset());
316 self.buffer.select_to(previous);
317 }
318 self.buffer.replace(None, "");
319 self.reset_preferred_x();
320 self.ensure_cursor_visible();
321 self.emit_change(window, cx);
322 cx.notify();
323 }
324
325 fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
326 if self.config.disabled || self.config.read_only {
327 return;
328 }
329 if self.buffer.selected.is_empty() {
330 let next = self.buffer.next_boundary(self.buffer.cursor_offset());
331 self.buffer.select_to(next);
332 }
333 self.buffer.replace(None, "");
334 self.reset_preferred_x();
335 self.ensure_cursor_visible();
336 self.emit_change(window, cx);
337 cx.notify();
338 }
339
340 fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context<Self>) {
341 if self.config.disabled || self.config.read_only {
342 return;
343 }
344 self.buffer.replace(None, "\n");
345 self.reset_preferred_x();
346 self.ensure_cursor_visible();
347 self.emit_change(window, cx);
348 cx.notify();
349 }
350
351 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
352 if !self.buffer.selected.is_empty() {
353 cx.write_to_clipboard(ClipboardItem::new_string(
354 self.buffer.content[self.buffer.selected.clone()].to_owned(),
355 ));
356 }
357 }
358
359 fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
360 if self.config.disabled || self.config.read_only || self.buffer.selected.is_empty() {
361 return;
362 }
363 self.copy(&Copy, window, cx);
364 self.buffer.replace(None, "");
365 self.ensure_cursor_visible();
366 self.emit_change(window, cx);
367 cx.notify();
368 }
369
370 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
371 if self.config.disabled || self.config.read_only {
372 return;
373 }
374 if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
375 self.buffer.replace(None, &text);
376 self.ensure_cursor_visible();
377 self.emit_change(window, cx);
378 cx.notify();
379 }
380 }
381
382 fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context<Self>) {
383 if !self.config.disabled && !self.config.read_only && self.buffer.undo() {
384 self.reset_preferred_x();
385 self.ensure_cursor_visible();
386 self.emit_change(window, cx);
387 cx.notify();
388 }
389 }
390
391 fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context<Self>) {
392 if !self.config.disabled && !self.config.read_only && self.buffer.redo() {
393 self.reset_preferred_x();
394 self.ensure_cursor_visible();
395 self.emit_change(window, cx);
396 cx.notify();
397 }
398 }
399
400 fn show_character_palette(
401 &mut self,
402 _: &ShowCharacterPalette,
403 window: &mut Window,
404 _: &mut Context<Self>,
405 ) {
406 if !self.config.disabled {
407 window.show_character_palette();
408 }
409 }
410
411 fn index_for_mouse(&self, position: Point<Pixels>) -> usize {
412 let (Some(bounds), Some(layout)) = (&self.last_bounds, &self.layout) else {
413 return 0;
414 };
415 layout.closest_index_for_position(position - bounds.origin)
416 }
417
418 fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
419 if self.config.disabled {
420 return;
421 }
422 self.focus.focus(window, cx);
423 self.selecting = true;
424 let index = self.index_for_mouse(event.position);
425 if event.modifiers.shift {
426 self.buffer.select_to(index);
427 } else {
428 self.buffer.move_to(index);
429 }
430 self.reset_preferred_x();
431 self.ensure_cursor_visible();
432 cx.notify();
433 }
434
435 fn mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
436 self.selecting = false;
437 }
438
439 fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
440 if self.selecting {
441 let index = self.index_for_mouse(event.position);
442 self.buffer.select_to(index);
443 self.reset_preferred_x();
444 self.ensure_cursor_visible();
445 cx.notify();
446 }
447 }
448}
449
450impl EntityInputHandler for TextAreaEntity {
451 fn text_for_range(
452 &mut self,
453 range_utf16: Range<usize>,
454 actual_range: &mut Option<Range<usize>>,
455 _: &mut Window,
456 _: &mut Context<Self>,
457 ) -> Option<String> {
458 let range = self.buffer.range_from_utf16(&range_utf16);
459 actual_range.replace(self.buffer.range_to_utf16(&range));
460 self.buffer.content.get(range).map(ToOwned::to_owned)
461 }
462
463 fn selected_text_range(
464 &mut self,
465 _ignore_disabled_input: bool,
466 _: &mut Window,
467 _: &mut Context<Self>,
468 ) -> Option<UTF16Selection> {
469 Some(UTF16Selection {
470 range: self.buffer.range_to_utf16(&self.buffer.selected),
471 reversed: self.buffer.selection_reversed,
472 })
473 }
474
475 fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
476 self.buffer
477 .marked
478 .as_ref()
479 .map(|range| self.buffer.range_to_utf16(range))
480 }
481
482 fn unmark_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
483 let was_marked = self.buffer.marked().is_some();
484 self.buffer.unmark();
485 if was_marked {
486 self.emit_change(window, cx);
487 cx.notify();
488 }
489 }
490
491 fn replace_text_in_range(
492 &mut self,
493 range_utf16: Option<Range<usize>>,
494 text: &str,
495 window: &mut Window,
496 cx: &mut Context<Self>,
497 ) {
498 if self.config.disabled || self.config.read_only {
499 return;
500 }
501 self.buffer.replace(range_utf16.as_ref(), text);
502 self.emit_change(window, cx);
503 cx.notify();
504 }
505
506 fn replace_and_mark_text_in_range(
507 &mut self,
508 range_utf16: Option<Range<usize>>,
509 text: &str,
510 selected_utf16: Option<Range<usize>>,
511 _: &mut Window,
512 cx: &mut Context<Self>,
513 ) {
514 if self.config.disabled || self.config.read_only {
515 return;
516 }
517 self.buffer
518 .replace_and_mark(range_utf16.as_ref(), text, selected_utf16);
519 cx.notify();
520 }
521
522 fn bounds_for_range(
523 &mut self,
524 range_utf16: Range<usize>,
525 bounds: Bounds<Pixels>,
526 _: &mut Window,
527 _: &mut Context<Self>,
528 ) -> Option<Bounds<Pixels>> {
529 let layout = self.layout.as_ref()?;
530 let range = self.buffer.range_from_utf16(&range_utf16);
531 let start = layout.position_for_index(range.start)?;
532 let end = layout.position_for_index(range.end)?;
533 Some(Bounds::from_corners(
534 bounds.origin + start,
535 bounds.origin
536 + point(
537 end.x.max(start.x + px(1.0)),
538 end.y + self.config.typography.line_height,
539 ),
540 ))
541 }
542
543 fn character_index_for_point(
544 &mut self,
545 point: Point<Pixels>,
546 _: &mut Window,
547 _: &mut Context<Self>,
548 ) -> Option<usize> {
549 let bounds = self.last_bounds?;
550 let index = self
551 .layout
552 .as_ref()?
553 .closest_index_for_position(point - bounds.origin);
554 Some(self.buffer.offset_to_utf16(index))
555 }
556}
557
558#[derive(Clone, Debug)]
559struct TextAreaLayout {
560 lines: Vec<Arc<WrappedLine>>,
561 starts: Vec<usize>,
562 row_starts: Vec<usize>,
563 line_height: Pixels,
564}
565
566impl TextAreaLayout {
567 fn new(lines: Vec<WrappedLine>, content: &str, line_height: Pixels) -> Self {
568 let mut starts = Vec::with_capacity(lines.len());
569 let mut offset = 0;
570 for line in content.split('\n') {
571 starts.push(offset);
572 offset += line.len() + 1;
573 }
574 while starts.len() < lines.len() {
575 starts.push(content.len());
576 }
577 starts.truncate(lines.len());
578 let mut row_starts = Vec::with_capacity(lines.len());
579 let mut row = 0;
580 for line in &lines {
581 row_starts.push(row);
582 row += line.wrap_boundaries().len() + 1;
583 }
584 Self {
585 lines: lines.into_iter().map(Arc::new).collect(),
586 starts,
587 row_starts,
588 line_height,
589 }
590 }
591
592 fn visual_rows(&self) -> usize {
593 self.lines
594 .iter()
595 .map(|line| line.wrap_boundaries().len() + 1)
596 .sum::<usize>()
597 .max(1)
598 }
599
600 fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
601 let line_index = self
602 .starts
603 .iter()
604 .enumerate()
605 .rev()
606 .find_map(|(line, start)| (*start <= index).then_some(line))?;
607 let line = &self.lines[line_index];
608 let local = index
609 .saturating_sub(self.starts[line_index])
610 .min(line.len());
611 let mut position = line.position_for_index(local, self.line_height)?;
612 position.y += self.line_height * self.row_starts[line_index];
613 Some(position)
614 }
615
616 fn closest_index_for_position(&self, position: Point<Pixels>) -> usize {
617 let row = pixels_to_usize((position.y / self.line_height).max(0.0));
618 let line_index = self
619 .row_starts
620 .iter()
621 .enumerate()
622 .rev()
623 .find_map(|(line, start)| (*start <= row).then_some(line))
624 .unwrap_or(0);
625 let line = &self.lines[line_index];
626 let local_y = position.y - self.line_height * self.row_starts[line_index];
627 let local = line
628 .closest_index_for_position(point(position.x, local_y), self.line_height)
629 .unwrap_or_else(|boundary| boundary);
630 self.starts[line_index] + local.min(line.len())
631 }
632
633 fn selection_quads(
634 &self,
635 selection: &Range<usize>,
636 origin: Point<Pixels>,
637 color: Rgba8,
638 ) -> Vec<PaintQuad> {
639 if selection.is_empty() {
640 return Vec::new();
641 }
642 let mut quads = Vec::new();
643 for (line_index, line) in self.lines.iter().enumerate() {
644 let global_start = self.starts[line_index];
645 let global_end = global_start + line.len();
646 if selection.end < global_start || selection.start > global_end {
647 continue;
648 }
649 for (visual_row, local_range) in visual_ranges(line).into_iter().enumerate() {
650 let row_global = global_start + local_range.start..global_start + local_range.end;
651 let start = selection.start.max(row_global.start);
652 let end = selection.end.min(row_global.end);
653 if start >= end {
654 continue;
655 }
656 let row_start_x = line.unwrapped_layout.x_for_index(local_range.start);
657 let x1 = line.unwrapped_layout.x_for_index(start - global_start) - row_start_x;
658 let x2 = line.unwrapped_layout.x_for_index(end - global_start) - row_start_x;
659 let y = self.line_height * (self.row_starts[line_index] + visual_row);
660 quads.push(fill(
661 Bounds::new(
662 origin + point(x1, y),
663 size((x2 - x1).max(px(1.0)), self.line_height),
664 ),
665 rgba(color.as_rgba_hex()),
666 ));
667 }
668 }
669 quads
670 }
671}
672
673fn visual_ranges(line: &WrappedLine) -> Vec<Range<usize>> {
674 let mut starts = vec![0];
675 for boundary in line.wrap_boundaries() {
676 let run = &line.runs()[boundary.run_ix];
677 starts.push(run.glyphs[boundary.glyph_ix].index);
678 }
679 let mut ranges = starts
680 .windows(2)
681 .map(|window| window[0]..window[1])
682 .collect::<Vec<_>>();
683 ranges.push(starts.last().copied().unwrap_or(0)..line.len());
684 ranges
685}
686
687struct TextAreaElement {
688 input: Entity<TextAreaEntity>,
689}
690
691struct TextAreaPrepaint {
692 layout: TextAreaLayout,
693 cursor: Option<PaintQuad>,
694 selection: Vec<PaintQuad>,
695}
696
697impl IntoElement for TextAreaElement {
698 type Element = Self;
699
700 fn into_element(self) -> Self::Element {
701 self
702 }
703}
704
705impl Element for TextAreaElement {
706 type RequestLayoutState = ();
707 type PrepaintState = TextAreaPrepaint;
708
709 fn id(&self) -> Option<ElementId> {
710 None
711 }
712
713 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
714 None
715 }
716
717 fn request_layout(
718 &mut self,
719 _: Option<&GlobalElementId>,
720 _: Option<&gpui::InspectorElementId>,
721 window: &mut Window,
722 cx: &mut App,
723 ) -> (LayoutId, ()) {
724 let input = self.input.read(cx);
725 let rows = input
726 .layout
727 .as_ref()
728 .map_or(input.measured_rows, TextAreaLayout::visual_rows);
729 let mut style = GpuiStyle::default();
730 style.size.width = relative(1.0).into();
731 style.size.height = (input.config.typography.line_height * rows).into();
732 (window.request_layout(style, [], cx), ())
733 }
734
735 fn prepaint(
736 &mut self,
737 _: Option<&GlobalElementId>,
738 _: Option<&gpui::InspectorElementId>,
739 bounds: Bounds<Pixels>,
740 (): &mut (),
741 window: &mut Window,
742 cx: &mut App,
743 ) -> TextAreaPrepaint {
744 let input = self.input.read(cx);
745 let content: SharedString = if input.buffer.content.is_empty() {
746 input.config.placeholder.clone()
747 } else {
748 input.buffer.content.clone().into()
749 };
750 let text_style = window.text_style();
751 let color = if input.buffer.content.is_empty() {
752 rgba(input.config.placeholder_color.as_rgba_hex()).into()
753 } else {
754 text_style.color
755 };
756 let base = TextRun {
757 len: content.len(),
758 font: text_style.font(),
759 color,
760 background_color: None,
761 underline: None,
762 strikethrough: None,
763 };
764 let runs = marked_runs(&base, input.buffer.marked.as_ref(), content.len());
765 let lines = window
766 .text_system()
767 .shape_text(
768 content,
769 input.config.typography.font_size,
770 &runs,
771 Some(bounds.size.width),
772 None,
773 )
774 .unwrap_or_default()
775 .into_vec();
776 let lines = if lines.is_empty() {
777 window
778 .text_system()
779 .shape_text(
780 " ".into(),
781 input.config.typography.font_size,
782 &[base],
783 Some(bounds.size.width),
784 None,
785 )
786 .unwrap_or_default()
787 .into_vec()
788 } else {
789 lines
790 };
791 let layout = TextAreaLayout::new(
792 lines,
793 &input.buffer.content,
794 input.config.typography.line_height,
795 );
796 let selection = layout.selection_quads(
797 &input.buffer.selected,
798 bounds.origin,
799 input.config.selection_color,
800 );
801 let cursor = input
802 .focus
803 .is_focused(window)
804 .then(|| layout.position_for_index(input.buffer.cursor_offset()))
805 .flatten()
806 .map(|position| {
807 fill(
808 Bounds::new(
809 bounds.origin + position,
810 size(px(1.5), input.config.typography.line_height),
811 ),
812 rgba(input.config.caret_color.as_rgba_hex()),
813 )
814 });
815 self.input.update(cx, |input, cx| {
816 let visual_rows = layout.visual_rows();
817 let measured = input.config.viewport_rows(visual_rows);
818 if measured != input.measured_rows {
819 input.measured_rows = measured;
820 cx.notify();
821 }
822 input.last_bounds = Some(bounds);
823 input.layout = Some(layout.clone());
824 });
825 TextAreaPrepaint {
826 layout,
827 cursor,
828 selection,
829 }
830 }
831
832 fn paint(
833 &mut self,
834 _: Option<&GlobalElementId>,
835 _: Option<&gpui::InspectorElementId>,
836 bounds: Bounds<Pixels>,
837 (): &mut (),
838 state: &mut TextAreaPrepaint,
839 window: &mut Window,
840 cx: &mut App,
841 ) {
842 let input = self.input.read(cx);
843 let focus = input.focus.clone();
844 let line_height = input.config.typography.line_height;
845 window.handle_input(
846 &focus,
847 ElementInputHandler::new(bounds, self.input.clone()),
848 cx,
849 );
850 for quad in state.selection.drain(..) {
851 window.paint_quad(quad);
852 }
853 for (index, line) in state.layout.lines.iter().enumerate() {
854 let y = line_height * state.layout.row_starts[index];
855 let _ = line.paint(
856 bounds.origin + point(px(0.0), y),
857 line_height,
858 TextAlign::Left,
859 Some(bounds),
860 window,
861 cx,
862 );
863 }
864 if let Some(cursor) = state.cursor.take() {
865 window.paint_quad(cursor);
866 }
867 }
868}
869
870fn marked_runs(base: &TextRun, marked: Option<&Range<usize>>, len: usize) -> Vec<TextRun> {
871 let Some(marked) = marked.filter(|range| range.end <= len) else {
872 return vec![base.clone()];
873 };
874 vec![
875 TextRun {
876 len: marked.start,
877 ..base.clone()
878 },
879 TextRun {
880 len: marked.end - marked.start,
881 underline: Some(UnderlineStyle {
882 color: Some(base.color),
883 thickness: px(1.0),
884 wavy: false,
885 }),
886 ..base.clone()
887 },
888 TextRun {
889 len: len - marked.end,
890 ..base.clone()
891 },
892 ]
893 .into_iter()
894 .filter(|run| run.len > 0)
895 .collect()
896}
897
898impl Render for TextAreaEntity {
899 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
900 let viewport_rows = self.config.viewport_rows(self.measured_rows);
901 let viewport_height = self.config.typography.line_height * viewport_rows;
902 let mut root = div().flex();
903 if let Some(family) = &self.config.typography.family {
904 root = root.font_family(family.clone());
905 }
906 if !self.config.typography.fallbacks.is_empty() {
907 root.text_style().font_fallbacks = Some(FontFallbacks::from_fonts(
908 self.config.typography.fallbacks.clone(),
909 ));
910 }
911 root.font_weight(FontWeight(f32::from(self.config.typography.weight)))
912 .key_context("GPUIRhaiTextarea")
913 .track_focus(&self.focus)
914 .on_key_down(|event: &KeyDownEvent, window, cx| {
915 if event.keystroke.key.as_str() == "tab" {
916 if event.keystroke.modifiers.shift {
917 window.focus_prev(cx);
918 } else {
919 window.focus_next(cx);
920 }
921 cx.stop_propagation();
922 }
923 })
924 .cursor(CursorStyle::IBeam)
925 .on_action(cx.listener(Self::backspace))
926 .on_action(cx.listener(Self::delete))
927 .on_action(cx.listener(Self::left))
928 .on_action(cx.listener(Self::right))
929 .on_action(cx.listener(Self::up))
930 .on_action(cx.listener(Self::down))
931 .on_action(cx.listener(Self::select_left))
932 .on_action(cx.listener(Self::select_right))
933 .on_action(cx.listener(Self::select_up))
934 .on_action(cx.listener(Self::select_down))
935 .on_action(cx.listener(Self::select_all))
936 .on_action(cx.listener(Self::home))
937 .on_action(cx.listener(Self::end))
938 .on_action(cx.listener(Self::newline))
939 .on_action(cx.listener(Self::show_character_palette))
940 .on_action(cx.listener(Self::paste))
941 .on_action(cx.listener(Self::cut))
942 .on_action(cx.listener(Self::copy))
943 .on_action(cx.listener(Self::undo))
944 .on_action(cx.listener(Self::redo))
945 .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
946 .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
947 .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
948 .on_mouse_move(cx.listener(Self::mouse_move))
949 .line_height(self.config.typography.line_height)
950 .text_size(self.config.typography.font_size)
951 .opacity(if self.config.disabled { 0.55 } else { 1.0 })
952 .child(
953 div()
954 .id("textarea-scroll")
955 .h(viewport_height)
956 .w_full()
957 .overflow_y_scroll()
958 .restrict_scroll_to_axis()
959 .track_scroll(&self.scroll)
960 .when_some(self.config.scroll_color, |scroll, color| {
961 scroll.bg(rgba(color.as_rgba_hex()))
962 })
963 .child(TextAreaElement { input: cx.entity() }),
964 )
965 }
966}
967
968impl Focusable for TextAreaEntity {
969 fn focus_handle(&self, _: &App) -> FocusHandle {
970 self.focus.clone()
971 }
972}
973
974#[derive(Default)]
975pub struct TextAreaPrimitiveHandler {
976 instances: BTreeMap<PrimitiveInstanceId, Entity<TextAreaEntity>>,
977}
978
979impl PrimitiveHandler for TextAreaPrimitiveHandler {
980 fn uses_primary_focus(&self) -> bool {
981 true
982 }
983
984 fn accessibility_actions(
985 &self,
986 _instance: &PrimitiveInstanceId,
987 ) -> Vec<gpui::AccessibleAction> {
988 vec![
989 gpui::AccessibleAction::Focus,
990 gpui::AccessibleAction::SetValue,
991 ]
992 }
993
994 fn perform_accessibility_action(
995 &mut self,
996 instance: &PrimitiveInstanceId,
997 action: gpui::AccessibleAction,
998 data: Option<&gpui::accesskit::ActionData>,
999 window: &mut Window,
1000 cx: &mut App,
1001 ) -> Result<(), String> {
1002 let entity = self
1003 .instances
1004 .get(instance)
1005 .cloned()
1006 .ok_or_else(|| "textarea accessibility target is stale".to_owned())?;
1007 match action {
1008 gpui::AccessibleAction::Focus => {
1009 let (disabled, focus) = {
1010 let input = entity.read(cx);
1011 (input.config.disabled, input.focus.clone())
1012 };
1013 if disabled {
1014 return Err("disabled textarea cannot receive focus".to_owned());
1015 }
1016 focus.focus(window, cx);
1017 Ok(())
1018 }
1019 gpui::AccessibleAction::SetValue => {
1020 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
1021 return Err("textarea SetValue requires string data".to_owned());
1022 };
1023 let callback = {
1024 let input = entity.read(cx);
1025 if input.config.disabled || input.config.read_only {
1026 return Err("textarea is disabled or read-only".to_owned());
1027 }
1028 input.callbacks.change.clone()
1029 };
1030 if let Some(callback) = callback {
1031 callback(value.to_string(), window, cx);
1032 }
1033 Ok(())
1034 }
1035 _ => Err("unsupported textarea accessibility action".to_owned()),
1036 }
1037 }
1038
1039 fn render(
1040 &mut self,
1041 instance: &PrimitiveInstance,
1042 events: &PrimitiveContext,
1043 theme: &crate::PrimitiveTheme,
1044 window: &mut Window,
1045 cx: &mut App,
1046 ) -> Result<gpui::AnyElement, String> {
1047 let id = instance
1048 .id
1049 .clone()
1050 .ok_or_else(|| "TextareaPrimitive requires a stable key".to_owned())?;
1051 if id.key().trim().is_empty() {
1052 return Err("TextareaPrimitive key cannot be empty".to_owned());
1053 }
1054 let value = instance
1055 .node
1056 .props
1057 .string("value")
1058 .unwrap_or_default()
1059 .to_owned();
1060 let config = config_from_props(&instance.node.props, theme, window)?;
1061 let callbacks = primitive_callbacks(events);
1062 let shared_focus = instance.focus_handle().cloned();
1063 let entity = if let Some(entity) = self.instances.get(&id) {
1064 entity.clone()
1065 } else {
1066 let entity = cx.new(|cx| {
1067 let mut input = TextAreaEntity::new(&value, config.clone(), callbacks.clone(), cx)
1068 .expect("validated Textarea props remain valid during entity creation");
1069 if let Some(focus) = shared_focus.clone() {
1070 input.focus = focus.tab_stop(!config.disabled);
1071 }
1072 input
1073 });
1074 entity.update(cx, |input, cx| {
1075 let focus = input.focus.clone();
1076 cx.on_focus(&focus, window, |input, window, cx| {
1077 if let Some(focus) = input.callbacks.focus.clone() {
1078 window.defer(cx, move |window, cx| focus(window, cx));
1079 }
1080 })
1081 .detach();
1082 cx.on_blur(&focus, window, |input, window, cx| {
1083 if let Some(blur) = input.callbacks.blur.clone() {
1084 window.defer(cx, move |window, cx| blur(window, cx));
1085 }
1086 })
1087 .detach();
1088 if input.config.autofocus && !input.config.disabled {
1089 input.focus.focus(window, cx);
1090 }
1091 });
1092 self.instances.insert(id.clone(), entity.clone());
1093 entity
1094 };
1095 entity.update(cx, |input, cx| {
1096 input.update_props(&value, config, callbacks, cx)
1097 })?;
1098 Ok(entity.into_any_element())
1099 }
1100
1101 fn unmount(&mut self, instance: &PrimitiveInstanceId) {
1102 self.instances.remove(instance);
1103 }
1104}
1105
1106fn validate_text_area_value(value: &str, config: &TextAreaConfig) -> Result<(), String> {
1107 if config.min_rows == 0 || config.max_rows < config.min_rows {
1108 return Err("Textarea requires 1 <= min_rows <= max_rows".to_owned());
1109 }
1110 if config.rows == Some(0) {
1111 return Err("Textarea rows must be greater than zero".to_owned());
1112 }
1113 if let Some(max_length) = config.max_length
1114 && unicode_segmentation::UnicodeSegmentation::graphemes(value, true).count() > max_length
1115 {
1116 return Err(format!(
1117 "Textarea controlled value exceeds max_length {max_length}"
1118 ));
1119 }
1120 Ok(())
1121}
1122
1123fn config_from_props(
1124 props: &PrimitiveProps,
1125 theme: &crate::PrimitiveTheme,
1126 window: &Window,
1127) -> Result<TextAreaConfig, String> {
1128 let accent = theme
1129 .color("accent")
1130 .unwrap_or_else(|| Rgba8::from_rgba_hex(0x3b82_f6ff));
1131 Ok(TextAreaConfig {
1132 placeholder: props.string("placeholder").unwrap_or_default().into(),
1133 disabled: props.boolean("disabled").unwrap_or(false),
1134 read_only: props.boolean("read_only").unwrap_or(false),
1135 min_rows: usize_prop(props, "min_rows")?.unwrap_or(3),
1136 max_rows: usize_prop(props, "max_rows")?.unwrap_or(8),
1137 rows: usize_prop(props, "rows")?,
1138 max_length: usize_prop(props, "max_length")?,
1139 typography: native_typography(theme, props.string("typography").unwrap_or("body"), window)?,
1140 autofocus: props.boolean("autofocus").unwrap_or(false),
1141 placeholder_color: part_color(props, "placeholder_style", theme, false)
1142 .or_else(|| theme.color("text_muted"))
1143 .unwrap_or_else(|| Rgba8::from_rgba_hex(0xa1a1_aaff)),
1144 selection_color: part_color(props, "selection_style", theme, true)
1145 .unwrap_or_else(|| with_alpha(accent, 0x55)),
1146 caret_color: part_color(props, "caret_style", theme, true).unwrap_or(accent),
1147 scroll_color: part_color(props, "scroll_style", theme, true),
1148 })
1149}
1150
1151fn part_color(
1152 props: &PrimitiveProps,
1153 name: &str,
1154 theme: &crate::PrimitiveTheme,
1155 prefer_background: bool,
1156) -> Option<Rgba8> {
1157 let Some(PrimitiveValue::Style(style)) = props.get(name) else {
1158 return None;
1159 };
1160 let resolved = style.resolve(&crate::InteractionState::default());
1161 let value = if prefer_background {
1162 resolved
1163 .background
1164 .as_ref()
1165 .or(resolved.text_color.as_ref())
1166 } else {
1167 resolved
1168 .text_color
1169 .as_ref()
1170 .or(resolved.background.as_ref())
1171 }?;
1172 theme.resolve_color(value)
1173}
1174
1175fn with_alpha(color: Rgba8, alpha: u8) -> Rgba8 {
1176 Rgba8::from_rgba_hex((color.as_rgba_hex() & 0xffff_ff00) | u32::from(alpha))
1177}
1178
1179fn primitive_callbacks(events: &PrimitiveContext) -> TextInputCallbacks {
1180 let change_events = events.clone();
1181 let focus_events = events.clone();
1182 let blur_events = events.clone();
1183 TextInputCallbacks {
1184 change: Some(Rc::new(move |value, window, cx| {
1185 let _ = change_events.emit("change", UiValue::String(value), window, cx);
1186 })),
1187 submit: None,
1188 focus: Some(Rc::new(move |window, cx| {
1189 let _ = focus_events.emit("focus", UiValue::Null, window, cx);
1190 })),
1191 blur: Some(Rc::new(move |window, cx| {
1192 let _ = blur_events.emit("blur", UiValue::Null, window, cx);
1193 })),
1194 tab: None,
1195 }
1196}
1197
1198fn usize_prop(props: &PrimitiveProps, name: &str) -> Result<Option<usize>, String> {
1199 match props.get(name) {
1200 Some(PrimitiveValue::Data(UiValue::Integer(value))) => usize::try_from(*value)
1201 .map(Some)
1202 .map_err(|_| format!("Textarea {name} must be a non-negative integer")),
1203 Some(PrimitiveValue::Data(UiValue::Null)) | None => Ok(None),
1204 _ => Err(format!("Textarea {name} must be an integer")),
1205 }
1206}
1207
1208fn pixels_to_usize(value: f32) -> usize {
1209 value.max(0.0).to_string().parse().unwrap_or(usize::MAX)
1210}
1211
1212#[must_use]
1218pub fn text_area_primitive_descriptor() -> PrimitiveDescriptor {
1219 let optional_callback = || ObjectField::optional(ValueSchema::optional(ValueSchema::Callback));
1220 PrimitiveDescriptor {
1221 id: PrimitiveId::parse("gpui_rhai.textarea").expect("static primitive ID"),
1222 export: "TextareaPrimitive".to_owned(),
1223 props: BTreeMap::from([
1224 (
1225 "value".to_owned(),
1226 ObjectField::required(ValueSchema::string()),
1227 ),
1228 (
1229 "placeholder".to_owned(),
1230 ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
1231 ),
1232 (
1233 "disabled".to_owned(),
1234 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
1235 ),
1236 (
1237 "read_only".to_owned(),
1238 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
1239 ),
1240 (
1241 "min_rows".to_owned(),
1242 ObjectField::optional(ValueSchema::bounded_integer(
1243 Some(1),
1244 Some(MAX_TEXTAREA_ROWS),
1245 ))
1246 .with_default(UiValue::Integer(3)),
1247 ),
1248 (
1249 "max_rows".to_owned(),
1250 ObjectField::optional(ValueSchema::bounded_integer(
1251 Some(1),
1252 Some(MAX_TEXTAREA_ROWS),
1253 ))
1254 .with_default(UiValue::Integer(8)),
1255 ),
1256 (
1257 "rows".to_owned(),
1258 ObjectField::optional(ValueSchema::optional(ValueSchema::bounded_integer(
1259 Some(1),
1260 Some(MAX_TEXTAREA_ROWS),
1261 ))),
1262 ),
1263 (
1264 "max_length".to_owned(),
1265 ObjectField::optional(ValueSchema::optional(ValueSchema::bounded_integer(
1266 Some(0),
1267 Some(MAX_TEXTAREA_GRAPHEMES),
1268 ))),
1269 ),
1270 (
1271 "typography".to_owned(),
1272 ObjectField::required(ValueSchema::String {
1273 allowed: crate::REQUIRED_TYPOGRAPHY
1274 .iter()
1275 .map(ToString::to_string)
1276 .collect(),
1277 }),
1278 ),
1279 (
1280 "autofocus".to_owned(),
1281 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
1282 ),
1283 (
1284 "placeholder_style".to_owned(),
1285 ObjectField::required(ValueSchema::Style),
1286 ),
1287 (
1288 "selection_style".to_owned(),
1289 ObjectField::required(ValueSchema::Style),
1290 ),
1291 (
1292 "caret_style".to_owned(),
1293 ObjectField::required(ValueSchema::Style),
1294 ),
1295 (
1296 "scroll_style".to_owned(),
1297 ObjectField::required(ValueSchema::Style),
1298 ),
1299 ("on_change".to_owned(), optional_callback()),
1300 ("on_focus".to_owned(), optional_callback()),
1301 ("on_blur".to_owned(), optional_callback()),
1302 ]),
1303 events: text_area_events(),
1304 state: ComponentStateSchema::default(),
1305 lifecycle: true,
1306 effect: None,
1307 }
1308}
1309
1310fn text_area_events() -> BTreeMap<String, EventSchema> {
1311 BTreeMap::from([
1312 (
1313 "change".to_owned(),
1314 EventSchema {
1315 payload: ValueSchema::string(),
1316 },
1317 ),
1318 (
1319 "focus".to_owned(),
1320 EventSchema {
1321 payload: ValueSchema::Null,
1322 },
1323 ),
1324 (
1325 "blur".to_owned(),
1326 EventSchema {
1327 payload: ValueSchema::Null,
1328 },
1329 ),
1330 ])
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::*;
1336
1337 #[test]
1338 fn visual_line_layout_maps_positions_and_selection() {
1339 let layout = TextAreaLayout {
1342 lines: Vec::new(),
1343 starts: Vec::new(),
1344 row_starts: Vec::new(),
1345 line_height: px(20.0),
1346 };
1347 assert_eq!(layout.visual_rows(), 1);
1348 }
1349
1350 #[test]
1351 fn config_rejects_controlled_values_over_grapheme_limit() {
1352 let config = TextAreaConfig {
1353 placeholder: "".into(),
1354 disabled: false,
1355 read_only: false,
1356 min_rows: 3,
1357 max_rows: 8,
1358 rows: None,
1359 max_length: Some(1),
1360 typography: NativeTypography {
1361 family: None,
1362 fallbacks: Vec::new(),
1363 font_size: px(14.0),
1364 line_height: px(20.0),
1365 weight: 400,
1366 },
1367 autofocus: false,
1368 placeholder_color: Rgba8::from_rgba_hex(0xa1a1_aaff),
1369 selection_color: Rgba8::from_rgba_hex(0x3b82_f655),
1370 caret_color: Rgba8::from_rgba_hex(0x3b82_f6ff),
1371 scroll_color: None,
1372 };
1373 assert!(validate_text_area_value("👩🏽💻", &config).is_ok());
1374 assert!(validate_text_area_value("👩🏽💻x", &config).is_err());
1375 }
1376}