1use std::collections::BTreeMap;
4use std::ops::Range;
5use std::rc::Rc;
6
7use gpui::{
8 App, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, Element, ElementId,
9 ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, FontFallbacks,
10 FontWeight, GlobalElementId, IntoElement, KeyBinding, KeyDownEvent, LayoutId, MouseButton,
11 MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render, ShapedLine,
12 SharedString, Style as GpuiStyle, TextAlign, TextRun, UTF16Selection, UnderlineStyle, Window,
13 actions, div, fill, point, prelude::*, px, relative, rgba, size,
14};
15
16pub use crate::text_edit::TextBuffer;
17
18use crate::{
19 ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
20 PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, Rgba8, UiValue,
21 ValueSchema,
22};
23
24actions!(
25 gpui_rhai_input,
26 [
27 Backspace,
28 Delete,
29 Left,
30 Right,
31 SelectLeft,
32 SelectRight,
33 SelectAll,
34 Home,
35 End,
36 Submit,
37 ShowCharacterPalette,
38 Paste,
39 Cut,
40 Copy,
41 Undo,
42 Redo,
43 ]
44);
45
46pub fn init_text_input(cx: &mut App) {
47 cx.bind_keys([
48 KeyBinding::new("backspace", Backspace, Some("GPUIRhaiTextInput")),
49 KeyBinding::new("delete", Delete, Some("GPUIRhaiTextInput")),
50 KeyBinding::new("left", Left, Some("GPUIRhaiTextInput")),
51 KeyBinding::new("right", Right, Some("GPUIRhaiTextInput")),
52 KeyBinding::new("shift-left", SelectLeft, Some("GPUIRhaiTextInput")),
53 KeyBinding::new("shift-right", SelectRight, Some("GPUIRhaiTextInput")),
54 KeyBinding::new("cmd-a", SelectAll, Some("GPUIRhaiTextInput")),
55 KeyBinding::new("cmd-v", Paste, Some("GPUIRhaiTextInput")),
56 KeyBinding::new("cmd-c", Copy, Some("GPUIRhaiTextInput")),
57 KeyBinding::new("cmd-x", Cut, Some("GPUIRhaiTextInput")),
58 KeyBinding::new("cmd-z", Undo, Some("GPUIRhaiTextInput")),
59 KeyBinding::new("shift-cmd-z", Redo, Some("GPUIRhaiTextInput")),
60 KeyBinding::new("home", Home, Some("GPUIRhaiTextInput")),
61 KeyBinding::new("end", End, Some("GPUIRhaiTextInput")),
62 KeyBinding::new("enter", Submit, Some("GPUIRhaiTextInput")),
63 KeyBinding::new(
64 "ctrl-cmd-space",
65 ShowCharacterPalette,
66 Some("GPUIRhaiTextInput"),
67 ),
68 ]);
69}
70
71pub(crate) type TextValueHandler = Rc<dyn Fn(String, &mut Window, &mut App)>;
72pub(crate) type TextSignalHandler = Rc<dyn Fn(&mut Window, &mut App)>;
73pub(crate) type TextTabHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
74
75#[derive(Clone, Default)]
76pub(crate) struct TextInputCallbacks {
77 pub change: Option<TextValueHandler>,
78 pub submit: Option<TextValueHandler>,
79 pub focus: Option<TextSignalHandler>,
80 pub blur: Option<TextSignalHandler>,
81 pub tab: Option<TextTabHandler>,
82}
83
84pub(crate) struct TextInputEntity {
85 focus: FocusHandle,
86 buffer: TextBuffer,
87 placeholder: SharedString,
88 disabled: bool,
89 read_only: bool,
90 selection_color: Rgba8,
91 caret_color: Rgba8,
92 typography: NativeTypography,
93 callbacks: TextInputCallbacks,
94 last_layout: Option<ShapedLine>,
95 last_bounds: Option<Bounds<Pixels>>,
96 scroll_x: Pixels,
97 selecting: bool,
98}
99
100#[derive(Clone)]
101pub(crate) struct TextInputConfig {
102 pub(crate) value: String,
103 pub(crate) placeholder: String,
104 pub(crate) disabled: bool,
105 pub(crate) read_only: bool,
106 pub(crate) autofocus: bool,
107 pub(crate) selection_color: Rgba8,
108 pub(crate) caret_color: Rgba8,
109 pub(crate) typography: NativeTypography,
110}
111
112#[derive(Clone, Debug)]
113pub(crate) struct NativeTypography {
114 pub(crate) family: Option<String>,
115 pub(crate) fallbacks: Vec<String>,
116 pub(crate) font_size: Pixels,
117 pub(crate) line_height: Pixels,
118 pub(crate) weight: u16,
119}
120
121pub(crate) fn native_typography(
122 theme: &crate::PrimitiveTheme,
123 role: &str,
124 window: &Window,
125) -> Result<NativeTypography, String> {
126 let typography = theme
127 .typography(role)
128 .ok_or_else(|| format!("native text primitive cannot resolve typography role `{role}`"))?;
129 Ok(NativeTypography {
130 family: typography.family,
131 fallbacks: typography.fallbacks,
132 font_size: native_length(typography.size, window.rem_size(), role, "size")?,
133 line_height: native_length(
134 typography.line_height,
135 window.rem_size(),
136 role,
137 "line_height",
138 )?,
139 weight: typography.weight,
140 })
141}
142
143fn native_length(
144 value: crate::Length,
145 rem_size: Pixels,
146 role: &str,
147 field: &str,
148) -> Result<Pixels, String> {
149 match value {
150 crate::Length::Pixels(value) => value
151 .to_string()
152 .parse()
153 .map(px)
154 .map_err(|_| format!("typography `{role}.{field}` cannot fit native f32 pixels")),
155 crate::Length::Rems(value) => value
156 .to_string()
157 .parse::<f32>()
158 .map(|value| rem_size * value)
159 .map_err(|_| format!("typography `{role}.{field}` cannot fit native f32 rems")),
160 crate::Length::Relative(_)
161 | crate::Length::ThemeSpacing(_)
162 | crate::Length::ThemeRadius(_) => Err(format!(
163 "typography `{role}.{field}` must resolve to pixels or rems"
164 )),
165 }
166}
167
168impl TextInputEntity {
169 pub(crate) fn callbacks(&self) -> TextInputCallbacks {
170 self.callbacks.clone()
171 }
172
173 pub(crate) fn new(
174 config: TextInputConfig,
175 callbacks: TextInputCallbacks,
176 cx: &mut Context<Self>,
177 ) -> Self {
178 Self {
179 focus: cx.focus_handle().tab_stop(!config.disabled),
180 buffer: TextBuffer::new(&config.value),
181 placeholder: config.placeholder.into(),
182 disabled: config.disabled,
183 read_only: config.read_only,
184 selection_color: config.selection_color,
185 caret_color: config.caret_color,
186 typography: config.typography,
187 callbacks,
188 last_layout: None,
189 last_bounds: None,
190 scroll_x: px(0.0),
191 selecting: false,
192 }
193 }
194
195 pub(crate) fn update_props(
196 &mut self,
197 config: &TextInputConfig,
198 callbacks: TextInputCallbacks,
199 cx: &mut Context<Self>,
200 ) {
201 self.buffer.set_controlled(&config.value);
202 self.focus = self.focus.clone().tab_stop(!config.disabled);
203 self.placeholder = config.placeholder.clone().into();
204 self.disabled = config.disabled;
205 self.read_only = config.read_only;
206 self.selection_color = config.selection_color;
207 self.caret_color = config.caret_color;
208 self.typography = config.typography.clone();
209 self.callbacks = callbacks;
210 cx.notify();
211 }
212
213 fn emit_change(&self, window: &mut Window, cx: &mut Context<Self>) {
214 if let Some(change) = self.callbacks.change.clone() {
215 let value = self.buffer.content.clone();
216 window.defer(cx, move |window, cx| change(value, window, cx));
217 }
218 }
219
220 fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
221 let offset = if self.buffer.selected.is_empty() {
222 self.buffer.previous_boundary(self.buffer.cursor_offset())
223 } else {
224 self.buffer.selected.start
225 };
226 self.buffer.move_to(offset);
227 cx.notify();
228 }
229
230 fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
231 let offset = if self.buffer.selected.is_empty() {
232 self.buffer.next_boundary(self.buffer.selected.end)
233 } else {
234 self.buffer.selected.end
235 };
236 self.buffer.move_to(offset);
237 cx.notify();
238 }
239
240 fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
241 let offset = self.buffer.previous_boundary(self.buffer.cursor_offset());
242 self.buffer.select_to(offset);
243 cx.notify();
244 }
245
246 fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
247 let offset = self.buffer.next_boundary(self.buffer.cursor_offset());
248 self.buffer.select_to(offset);
249 cx.notify();
250 }
251
252 fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
253 self.buffer.move_to(0);
254 self.buffer.select_to(self.buffer.content.len());
255 cx.notify();
256 }
257
258 fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
259 self.buffer.move_to(0);
260 cx.notify();
261 }
262
263 fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
264 self.buffer.move_to(self.buffer.content.len());
265 cx.notify();
266 }
267
268 fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
269 if self.disabled || self.read_only {
270 return;
271 }
272 if self.buffer.selected.is_empty() {
273 let previous = self.buffer.previous_boundary(self.buffer.cursor_offset());
274 self.buffer.select_to(previous);
275 }
276 self.buffer.replace(None, "");
277 self.emit_change(window, cx);
278 cx.notify();
279 }
280
281 fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
282 if self.disabled || self.read_only {
283 return;
284 }
285 if self.buffer.selected.is_empty() {
286 let next = self.buffer.next_boundary(self.buffer.cursor_offset());
287 self.buffer.select_to(next);
288 }
289 self.buffer.replace(None, "");
290 self.emit_change(window, cx);
291 cx.notify();
292 }
293
294 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
295 if !self.buffer.selected.is_empty() {
296 cx.write_to_clipboard(ClipboardItem::new_string(
297 self.buffer.content[self.buffer.selected.clone()].to_owned(),
298 ));
299 }
300 }
301
302 fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
303 if self.disabled || self.read_only || self.buffer.selected.is_empty() {
304 return;
305 }
306 self.copy(&Copy, window, cx);
307 self.buffer.replace(None, "");
308 self.emit_change(window, cx);
309 cx.notify();
310 }
311
312 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
313 if self.disabled || self.read_only {
314 return;
315 }
316 if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
317 self.buffer.replace(None, &text.replace('\n', " "));
318 self.emit_change(window, cx);
319 cx.notify();
320 }
321 }
322
323 fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context<Self>) {
324 if !self.disabled && !self.read_only && self.buffer.undo() {
325 self.emit_change(window, cx);
326 cx.notify();
327 }
328 }
329
330 fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context<Self>) {
331 if !self.disabled && !self.read_only && self.buffer.redo() {
332 self.emit_change(window, cx);
333 cx.notify();
334 }
335 }
336
337 fn submit(&mut self, _: &Submit, window: &mut Window, cx: &mut Context<Self>) {
338 if !self.disabled
339 && let Some(submit) = self.callbacks.submit.clone()
340 {
341 let value = self.buffer.content.clone();
342 window.defer(cx, move |window, cx| submit(value, window, cx));
343 }
344 }
345
346 fn show_character_palette(
347 &mut self,
348 _: &ShowCharacterPalette,
349 window: &mut Window,
350 _: &mut Context<Self>,
351 ) {
352 if !self.disabled {
353 window.show_character_palette();
354 }
355 }
356
357 fn index_for_mouse(&self, position: Point<Pixels>) -> usize {
358 let (Some(bounds), Some(line)) = (&self.last_bounds, &self.last_layout) else {
359 return 0;
360 };
361 if position.y < bounds.top() {
362 0
363 } else if position.y > bounds.bottom() {
364 self.buffer.content.len()
365 } else {
366 line.closest_index_for_x(position.x - bounds.left() + self.scroll_x)
367 }
368 }
369
370 fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
371 if self.disabled {
372 return;
373 }
374 self.focus.focus(window, cx);
375 self.selecting = true;
376 let index = self.index_for_mouse(event.position);
377 if event.modifiers.shift {
378 self.buffer.select_to(index);
379 } else {
380 self.buffer.move_to(index);
381 }
382 cx.notify();
383 }
384
385 fn mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
386 self.selecting = false;
387 }
388
389 fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
390 if self.selecting {
391 let index = self.index_for_mouse(event.position);
392 self.buffer.select_to(index);
393 cx.notify();
394 }
395 }
396}
397
398impl EntityInputHandler for TextInputEntity {
399 fn text_for_range(
400 &mut self,
401 range_utf16: Range<usize>,
402 actual_range: &mut Option<Range<usize>>,
403 _: &mut Window,
404 _: &mut Context<Self>,
405 ) -> Option<String> {
406 let range = self.buffer.range_from_utf16(&range_utf16);
407 actual_range.replace(self.buffer.range_to_utf16(&range));
408 self.buffer.content.get(range).map(ToOwned::to_owned)
409 }
410
411 fn selected_text_range(
412 &mut self,
413 _ignore_disabled_input: bool,
414 _: &mut Window,
415 _: &mut Context<Self>,
416 ) -> Option<UTF16Selection> {
417 Some(UTF16Selection {
418 range: self.buffer.range_to_utf16(&self.buffer.selected),
419 reversed: self.buffer.selection_reversed,
420 })
421 }
422
423 fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
424 self.buffer
425 .marked
426 .as_ref()
427 .map(|range| self.buffer.range_to_utf16(range))
428 }
429
430 fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
431 self.buffer.unmark();
432 }
433
434 fn replace_text_in_range(
435 &mut self,
436 range_utf16: Option<Range<usize>>,
437 text: &str,
438 window: &mut Window,
439 cx: &mut Context<Self>,
440 ) {
441 if self.disabled || self.read_only {
442 return;
443 }
444 self.buffer.replace(range_utf16.as_ref(), text);
445 self.emit_change(window, cx);
446 cx.notify();
447 }
448
449 fn replace_and_mark_text_in_range(
450 &mut self,
451 range_utf16: Option<Range<usize>>,
452 text: &str,
453 selected_utf16: Option<Range<usize>>,
454 window: &mut Window,
455 cx: &mut Context<Self>,
456 ) {
457 if self.disabled || self.read_only {
458 return;
459 }
460 self.buffer
461 .replace_and_mark(range_utf16.as_ref(), text, selected_utf16);
462 self.emit_change(window, cx);
463 cx.notify();
464 }
465
466 fn bounds_for_range(
467 &mut self,
468 range_utf16: Range<usize>,
469 bounds: Bounds<Pixels>,
470 _: &mut Window,
471 _: &mut Context<Self>,
472 ) -> Option<Bounds<Pixels>> {
473 let line = self.last_layout.as_ref()?;
474 let range = self.buffer.range_from_utf16(&range_utf16);
475 Some(Bounds::from_corners(
476 point(
477 bounds.left() + line.x_for_index(range.start) - self.scroll_x,
478 bounds.top(),
479 ),
480 point(
481 bounds.left() + line.x_for_index(range.end) - self.scroll_x,
482 bounds.bottom(),
483 ),
484 ))
485 }
486
487 fn character_index_for_point(
488 &mut self,
489 point: Point<Pixels>,
490 _: &mut Window,
491 _: &mut Context<Self>,
492 ) -> Option<usize> {
493 let bounds = self.last_bounds?;
494 if !bounds.contains(&point) {
495 return None;
496 }
497 let line = self.last_layout.as_ref()?;
498 let index = line.index_for_x(point.x - bounds.left() + self.scroll_x)?;
499 Some(self.buffer.offset_to_utf16(index))
500 }
501}
502
503struct TextElement {
504 input: Entity<TextInputEntity>,
505}
506
507struct TextPrepaint {
508 line: Option<ShapedLine>,
509 cursor: Option<PaintQuad>,
510 selection: Option<PaintQuad>,
511 origin: Point<Pixels>,
512 scroll_x: Pixels,
513}
514
515fn horizontal_scroll_for_cursor(
516 line_width: Pixels,
517 cursor_x: Pixels,
518 viewport_width: Pixels,
519 current: Pixels,
520) -> Pixels {
521 let viewport_width = viewport_width.max(px(0.0));
522 let max_scroll = (line_width - viewport_width).max(px(0.0));
523 let mut scroll_x = current.min(max_scroll);
524 if cursor_x < scroll_x {
525 scroll_x = cursor_x;
526 } else if cursor_x > scroll_x + viewport_width - px(2.0) {
527 scroll_x = (cursor_x - viewport_width + px(2.0)).min(max_scroll);
528 }
529 scroll_x.max(px(0.0))
530}
531
532impl IntoElement for TextElement {
533 type Element = Self;
534
535 fn into_element(self) -> Self::Element {
536 self
537 }
538}
539
540impl Element for TextElement {
541 type RequestLayoutState = ();
542 type PrepaintState = TextPrepaint;
543
544 fn id(&self) -> Option<ElementId> {
545 None
546 }
547
548 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
549 None
550 }
551
552 fn request_layout(
553 &mut self,
554 _: Option<&GlobalElementId>,
555 _: Option<&gpui::InspectorElementId>,
556 window: &mut Window,
557 cx: &mut App,
558 ) -> (LayoutId, ()) {
559 let mut style = GpuiStyle::default();
560 style.size.width = relative(1.0).into();
561 style.size.height = window.line_height().into();
562 (window.request_layout(style, [], cx), ())
563 }
564
565 fn prepaint(
566 &mut self,
567 _: Option<&GlobalElementId>,
568 _: Option<&gpui::InspectorElementId>,
569 bounds: Bounds<Pixels>,
570 _request_layout: &mut (),
571 window: &mut Window,
572 cx: &mut App,
573 ) -> TextPrepaint {
574 let input = self.input.read(cx);
575 let content: SharedString = input.buffer.content.clone().into();
576 let selected = input.buffer.selected.clone();
577 let cursor = input.buffer.cursor_offset();
578 let text_style = window.text_style();
579 let (display, color) = if content.is_empty() {
580 (input.placeholder.clone(), text_style.color.opacity(0.55))
581 } else {
582 (content, text_style.color)
583 };
584 let run = TextRun {
585 len: display.len(),
586 font: text_style.font(),
587 color,
588 background_color: None,
589 underline: None,
590 strikethrough: None,
591 };
592 let runs = if let Some(marked) = input.buffer.marked.as_ref() {
593 vec![
594 TextRun {
595 len: marked.start,
596 ..run.clone()
597 },
598 TextRun {
599 len: marked.end - marked.start,
600 underline: Some(UnderlineStyle {
601 color: Some(run.color),
602 thickness: px(1.0),
603 wavy: false,
604 }),
605 ..run.clone()
606 },
607 TextRun {
608 len: display.len() - marked.end,
609 ..run
610 },
611 ]
612 .into_iter()
613 .filter(|run| run.len > 0)
614 .collect()
615 } else {
616 vec![run]
617 };
618 let font_size = text_style.font_size.to_pixels(window.rem_size());
619 let line = window
620 .text_system()
621 .shape_line(display, font_size, &runs, None);
622 let cursor_x = line.x_for_index(cursor);
623 let scroll_x =
624 horizontal_scroll_for_cursor(line.width, cursor_x, bounds.size.width, input.scroll_x);
625 let origin = point(bounds.left() - scroll_x, bounds.top());
626 let (selection, cursor) = if selected.is_empty() {
627 (
628 None,
629 Some(fill(
630 Bounds::new(
631 point(origin.x + cursor_x, bounds.top()),
632 size(px(1.5), bounds.size.height),
633 ),
634 rgba(input.caret_color.as_rgba_hex()),
635 )),
636 )
637 } else {
638 (
639 Some(fill(
640 Bounds::from_corners(
641 point(origin.x + line.x_for_index(selected.start), bounds.top()),
642 point(origin.x + line.x_for_index(selected.end), bounds.bottom()),
643 ),
644 rgba(input.selection_color.as_rgba_hex()),
645 )),
646 None,
647 )
648 };
649 TextPrepaint {
650 line: Some(line),
651 cursor,
652 selection,
653 origin,
654 scroll_x,
655 }
656 }
657
658 fn paint(
659 &mut self,
660 _: Option<&GlobalElementId>,
661 _: Option<&gpui::InspectorElementId>,
662 bounds: Bounds<Pixels>,
663 _request_layout: &mut (),
664 state: &mut TextPrepaint,
665 window: &mut Window,
666 cx: &mut App,
667 ) {
668 let focus = self.input.read(cx).focus.clone();
669 window.handle_input(
670 &focus,
671 ElementInputHandler::new(bounds, self.input.clone()),
672 cx,
673 );
674 let Some(line) = state.line.take() else {
675 return;
676 };
677 window.with_content_mask(Some(ContentMask { bounds }), |window| {
678 if let Some(selection) = state.selection.take() {
679 window.paint_quad(selection);
680 }
681 let _ = line.paint(
682 state.origin,
683 window.line_height(),
684 TextAlign::Left,
685 None,
686 window,
687 cx,
688 );
689 if focus.is_focused(window)
690 && let Some(cursor) = state.cursor.take()
691 {
692 window.paint_quad(cursor);
693 }
694 });
695 self.input.update(cx, |input, _| {
696 input.last_layout = Some(line);
697 input.last_bounds = Some(bounds);
698 input.scroll_x = state.scroll_x;
699 });
700 }
701}
702
703impl Render for TextInputEntity {
704 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
705 let tab = self.callbacks.tab.clone();
706 let submit_enabled = self.callbacks.submit.is_some();
707 let mut root = div().flex().size_full().items_center();
708 if let Some(family) = &self.typography.family {
709 root = root.font_family(family.clone());
710 }
711 if !self.typography.fallbacks.is_empty() {
712 root.text_style().font_fallbacks =
713 Some(FontFallbacks::from_fonts(self.typography.fallbacks.clone()));
714 }
715 root.font_weight(FontWeight(f32::from(self.typography.weight)))
716 .key_context("GPUIRhaiTextInput")
717 .track_focus(&self.focus)
718 .on_key_down(move |event: &KeyDownEvent, window, cx| {
719 if event.keystroke.key.as_str() == "tab" {
720 if let Some(tab) = &tab {
721 let tab = tab.clone();
722 let shift = event.keystroke.modifiers.shift;
723 window.defer(cx, move |window, cx| tab(shift, window, cx));
724 } else if event.keystroke.modifiers.shift {
725 window.focus_prev(cx);
726 } else {
727 window.focus_next(cx);
728 }
729 cx.stop_propagation();
730 }
731 })
732 .cursor(CursorStyle::IBeam)
733 .on_action(cx.listener(Self::backspace))
734 .on_action(cx.listener(Self::delete))
735 .on_action(cx.listener(Self::left))
736 .on_action(cx.listener(Self::right))
737 .on_action(cx.listener(Self::select_left))
738 .on_action(cx.listener(Self::select_right))
739 .on_action(cx.listener(Self::select_all))
740 .on_action(cx.listener(Self::home))
741 .on_action(cx.listener(Self::end))
742 .when(submit_enabled, |input| {
743 input.on_action(cx.listener(Self::submit))
744 })
745 .on_action(cx.listener(Self::show_character_palette))
746 .on_action(cx.listener(Self::paste))
747 .on_action(cx.listener(Self::cut))
748 .on_action(cx.listener(Self::copy))
749 .on_action(cx.listener(Self::undo))
750 .on_action(cx.listener(Self::redo))
751 .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
752 .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
753 .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
754 .on_mouse_move(cx.listener(Self::mouse_move))
755 .line_height(self.typography.line_height)
756 .text_size(self.typography.font_size)
757 .opacity(if self.disabled { 0.55 } else { 1.0 })
758 .child(TextElement { input: cx.entity() })
759 }
760}
761
762impl Focusable for TextInputEntity {
763 fn focus_handle(&self, _: &App) -> FocusHandle {
764 self.focus.clone()
765 }
766}
767
768#[derive(Default)]
769pub struct TextInputPrimitiveHandler {
770 instances: BTreeMap<PrimitiveInstanceId, Entity<TextInputEntity>>,
771}
772
773impl PrimitiveHandler for TextInputPrimitiveHandler {
774 fn uses_primary_focus(&self) -> bool {
775 true
776 }
777
778 fn accessibility_actions(
779 &self,
780 _instance: &PrimitiveInstanceId,
781 ) -> Vec<gpui::AccessibleAction> {
782 vec![
783 gpui::AccessibleAction::Focus,
784 gpui::AccessibleAction::SetValue,
785 ]
786 }
787
788 fn perform_accessibility_action(
789 &mut self,
790 instance: &PrimitiveInstanceId,
791 action: gpui::AccessibleAction,
792 data: Option<&gpui::accesskit::ActionData>,
793 window: &mut Window,
794 cx: &mut App,
795 ) -> Result<(), String> {
796 let entity = self
797 .instances
798 .get(instance)
799 .cloned()
800 .ok_or_else(|| "text input accessibility target is stale".to_owned())?;
801 match action {
802 gpui::AccessibleAction::Focus => {
803 let (disabled, focus) = {
804 let input = entity.read(cx);
805 (input.disabled, input.focus.clone())
806 };
807 if disabled {
808 return Err("disabled text input cannot receive focus".to_owned());
809 }
810 focus.focus(window, cx);
811 Ok(())
812 }
813 gpui::AccessibleAction::SetValue => {
814 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
815 return Err("text input SetValue requires string data".to_owned());
816 };
817 let callback = {
818 let input = entity.read(cx);
819 if input.disabled || input.read_only {
820 return Err("text input is disabled or read-only".to_owned());
821 }
822 input.callbacks.change.clone()
823 };
824 if let Some(callback) = callback {
825 callback(value.to_string(), window, cx);
826 }
827 Ok(())
828 }
829 _ => Err("unsupported text input accessibility action".to_owned()),
830 }
831 }
832
833 fn render(
834 &mut self,
835 instance: &PrimitiveInstance,
836 events: &PrimitiveContext,
837 theme: &crate::PrimitiveTheme,
838 window: &mut Window,
839 cx: &mut App,
840 ) -> Result<gpui::AnyElement, String> {
841 let id = instance
842 .id
843 .clone()
844 .ok_or_else(|| "TextInputPrimitive requires a stable key".to_owned())?;
845 let value = instance
846 .node
847 .props
848 .string("value")
849 .unwrap_or_default()
850 .to_owned();
851 let placeholder = instance
852 .node
853 .props
854 .string("placeholder")
855 .unwrap_or_default()
856 .to_owned();
857 let disabled = instance.node.props.boolean("disabled").unwrap_or(false);
858 let read_only = instance.node.props.boolean("read_only").unwrap_or(false);
859 let autofocus = instance.node.props.boolean("autofocus").unwrap_or(false);
860 let typography_role = instance
861 .node
862 .props
863 .string("typography")
864 .unwrap_or("body")
865 .to_owned();
866 let selection_color = theme
867 .color("selection")
868 .unwrap_or_else(|| Rgba8::from_rgba_hex(0x292e_42ff));
869 let caret_color = theme
870 .color("accent")
871 .unwrap_or_else(|| Rgba8::from_rgba_hex(0x7aa2_f7ff));
872 let config = TextInputConfig {
873 value,
874 placeholder,
875 disabled,
876 read_only,
877 autofocus,
878 selection_color,
879 caret_color,
880 typography: native_typography(theme, &typography_role, window)?,
881 };
882 let callbacks = primitive_callbacks(events);
883 let shared_focus = instance.focus_handle().cloned();
884 let entity = if let Some(entity) = self.instances.get(&id) {
885 entity.clone()
886 } else {
887 let entity = cx.new(|cx| {
888 let mut input = TextInputEntity::new(config.clone(), callbacks.clone(), cx);
889 if let Some(focus) = shared_focus.clone() {
890 input.focus = focus.tab_stop(!config.disabled);
891 }
892 input
893 });
894 entity.update(cx, |input, cx| {
895 let focus = input.focus.clone();
896 cx.on_focus(&focus, window, |input, window, cx| {
897 if let Some(focus) = input.callbacks.focus.clone() {
898 window.defer(cx, move |window, cx| focus(window, cx));
899 }
900 })
901 .detach();
902 cx.on_blur(&focus, window, |input, window, cx| {
903 if let Some(blur) = input.callbacks.blur.clone() {
904 window.defer(cx, move |window, cx| blur(window, cx));
905 }
906 })
907 .detach();
908 if config.autofocus && !config.disabled {
910 input.focus.focus(window, cx);
911 }
912 });
913 self.instances.insert(id.clone(), entity.clone());
914 entity
915 };
916 entity.update(cx, |input, cx| {
917 input.update_props(&config, callbacks, cx);
918 });
919 Ok(entity.into_any_element())
920 }
921
922 fn unmount(&mut self, instance: &PrimitiveInstanceId) {
923 self.instances.remove(instance);
924 }
925}
926
927fn primitive_callbacks(events: &PrimitiveContext) -> TextInputCallbacks {
928 let change_events = events.clone();
929 let submit_events = events.clone();
930 let focus_events = events.clone();
931 let blur_events = events.clone();
932 TextInputCallbacks {
933 change: Some(Rc::new(move |value, window, cx| {
934 let _ = change_events.emit("change", UiValue::String(value), window, cx);
935 })),
936 submit: Some(Rc::new(move |value, window, cx| {
937 let _ = submit_events.emit("submit", UiValue::String(value), window, cx);
938 })),
939 focus: Some(Rc::new(move |window, cx| {
940 let _ = focus_events.emit("focus", UiValue::Null, window, cx);
941 })),
942 blur: Some(Rc::new(move |window, cx| {
943 let _ = blur_events.emit("blur", UiValue::Null, window, cx);
944 })),
945 tab: None,
946 }
947}
948
949#[must_use]
955pub fn text_input_primitive_descriptor() -> PrimitiveDescriptor {
956 let optional_callback = || ObjectField::optional(ValueSchema::optional(ValueSchema::Callback));
957 PrimitiveDescriptor {
958 id: PrimitiveId::parse("gpui_rhai.text_input").expect("static primitive ID"),
959 export: "TextInputPrimitive".to_owned(),
960 props: BTreeMap::from([
961 (
962 "value".to_owned(),
963 ObjectField::required(ValueSchema::string()),
964 ),
965 (
966 "placeholder".to_owned(),
967 ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
968 ),
969 (
970 "disabled".to_owned(),
971 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
972 ),
973 (
974 "read_only".to_owned(),
975 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
976 ),
977 (
978 "autofocus".to_owned(),
979 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
980 ),
981 (
982 "typography".to_owned(),
983 ObjectField::required(ValueSchema::String {
984 allowed: crate::REQUIRED_TYPOGRAPHY
985 .iter()
986 .map(ToString::to_string)
987 .collect(),
988 }),
989 ),
990 ("on_change".to_owned(), optional_callback()),
991 ("on_submit".to_owned(), optional_callback()),
992 ("on_focus".to_owned(), optional_callback()),
993 ("on_blur".to_owned(), optional_callback()),
994 ]),
995 events: BTreeMap::from([
996 (
997 "change".to_owned(),
998 EventSchema {
999 payload: ValueSchema::string(),
1000 },
1001 ),
1002 (
1003 "submit".to_owned(),
1004 EventSchema {
1005 payload: ValueSchema::string(),
1006 },
1007 ),
1008 (
1009 "focus".to_owned(),
1010 EventSchema {
1011 payload: ValueSchema::Null,
1012 },
1013 ),
1014 (
1015 "blur".to_owned(),
1016 EventSchema {
1017 payload: ValueSchema::Null,
1018 },
1019 ),
1020 ]),
1021 state: ComponentStateSchema::default(),
1022 lifecycle: true,
1023 effect: None,
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use super::*;
1030
1031 #[test]
1032 fn grapheme_navigation_does_not_split_emoji_or_combining_text() {
1033 let buffer = TextBuffer::new("aπ©βπ»e\u{301}");
1034 let end = buffer.content().len();
1035 let before_combining = buffer.previous_boundary(end);
1036 let before_emoji = buffer.previous_boundary(before_combining);
1037 assert_eq!(&buffer.content()[before_combining..], "e\u{301}");
1038 assert_eq!(&buffer.content()[before_emoji..before_combining], "π©βπ»");
1039 }
1040
1041 #[test]
1042 fn utf16_round_trip_supports_cjk_and_surrogate_pairs() {
1043 let buffer = TextBuffer::new("δΈπζ");
1044 for offset in [0, 3, 7, buffer.content().len()] {
1045 let utf16 = buffer.offset_to_utf16(offset);
1046 assert_eq!(buffer.offset_from_utf16(utf16), offset);
1047 }
1048 }
1049
1050 #[test]
1051 fn horizontal_scroll_keeps_the_active_cursor_inside_the_viewport() {
1052 assert_eq!(
1053 horizontal_scroll_for_cursor(px(200.0), px(150.0), px(100.0), px(0.0)),
1054 px(52.0)
1055 );
1056 assert_eq!(
1057 horizontal_scroll_for_cursor(px(200.0), px(10.0), px(100.0), px(50.0)),
1058 px(10.0)
1059 );
1060 assert_eq!(
1061 horizontal_scroll_for_cursor(px(80.0), px(80.0), px(100.0), px(30.0)),
1062 px(0.0)
1063 );
1064 }
1065
1066 #[test]
1067 fn marked_text_replacement_tracks_ime_range() {
1068 let mut buffer = TextBuffer::new("");
1069 buffer.replace_and_mark(None, "γ«", Some(1..1));
1070 assert_eq!(buffer.content(), "γ«");
1071 assert_eq!(buffer.marked(), Some(&(0..3)));
1072 buffer.replace(None, "ζ₯ζ¬");
1073 assert_eq!(buffer.content(), "ζ₯ζ¬");
1074 assert!(buffer.marked().is_none());
1075 }
1076}