1use fret_core::{
6 CaretAffinity, Color, Corners, Edges, Point, Px, Rect, Size, TextMetrics, TextStyle, TextWrap,
7};
8use fret_runtime::Effect;
9
10use crate::widget::{CommandCx, EventCx};
11use crate::{Invalidation, Theme, ThemeColorKey, ThemeMetricKey, UiHost};
12
13trait TextAreaUiCx {
14 fn invalidate_self(&mut self, kind: Invalidation);
15 fn request_redraw(&mut self);
16}
17
18impl<'a, H: UiHost> TextAreaUiCx for EventCx<'a, H> {
19 fn invalidate_self(&mut self, kind: Invalidation) {
20 EventCx::invalidate_self(self, kind);
21 }
22
23 fn request_redraw(&mut self) {
24 EventCx::request_redraw(self);
25 }
26}
27
28impl<'a, H: UiHost> TextAreaUiCx for CommandCx<'a, H> {
29 fn invalidate_self(&mut self, kind: Invalidation) {
30 CommandCx::invalidate_self(self, kind);
31 }
32
33 fn request_redraw(&mut self) {
34 CommandCx::request_redraw(self);
35 }
36}
37
38mod bound;
39mod widget;
40
41pub use bound::BoundTextArea;
42
43#[cfg(test)]
44mod tests;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47struct PreparedKey {
48 max_width_bits: u32,
49 wrap: TextWrap,
50 scale_bits: u32,
51 show_scrollbar: bool,
52 font_stack_key: u64,
53}
54
55#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
56struct ImeSurroundingTextCacheKey {
57 text_revision: u64,
58 caret: usize,
59 selection_anchor: usize,
60}
61
62#[derive(Debug, Default, Clone)]
63struct ImeSurroundingTextCache {
64 key: Option<ImeSurroundingTextCacheKey>,
65 value: Option<fret_runtime::WindowImeSurroundingText>,
66}
67
68#[derive(Debug, Clone)]
69pub struct TextAreaStyle {
70 pub padding_x: Px,
71 pub padding_y: Px,
72 pub background: Color,
73 pub border: Edges,
74 pub border_color: Color,
75 pub border_color_focused: Color,
80 pub focus_ring: Option<crate::element::RingStyle>,
81 pub corner_radii: Corners,
82 pub text_color: Color,
83 pub placeholder_color: Color,
84 pub selection_color: Color,
85 pub caret_color: Color,
86 pub preedit_bg_color: Color,
87 pub preedit_underline_color: Color,
88}
89
90impl Default for TextAreaStyle {
91 fn default() -> Self {
92 let border_color = Color {
93 r: 0.0,
94 g: 0.0,
95 b: 0.0,
96 a: 0.35,
97 };
98 Self {
99 padding_x: Px(10.0),
100 padding_y: Px(10.0),
101 background: Color {
102 r: 0.12,
103 g: 0.12,
104 b: 0.16,
105 a: 1.0,
106 },
107 border: Edges::all(Px(1.0)),
108 border_color,
109 border_color_focused: border_color,
110 focus_ring: None,
111 corner_radii: Corners::all(Px(8.0)),
112 text_color: Color {
113 r: 0.92,
114 g: 0.92,
115 b: 0.92,
116 a: 1.0,
117 },
118 placeholder_color: Color {
119 r: 0.92,
120 g: 0.92,
121 b: 0.92,
122 a: 0.5,
123 },
124 selection_color: Color {
125 r: 0.24,
126 g: 0.34,
127 b: 0.52,
128 a: 0.65,
129 },
130 caret_color: Color {
131 r: 0.90,
132 g: 0.90,
133 b: 0.92,
134 a: 1.0,
135 },
136 preedit_bg_color: Color {
137 r: 0.24,
138 g: 0.34,
139 b: 0.52,
140 a: 0.22,
141 },
142 preedit_underline_color: Color {
143 r: 0.65,
144 g: 0.82,
145 b: 1.0,
146 a: 0.95,
147 },
148 }
149 }
150}
151
152#[derive(Debug)]
153pub struct TextArea {
154 enabled: bool,
155 focusable: bool,
156 focus_ring_always_paint: bool,
157 text: String,
158 base_text_revision: u64,
159 ime_surrounding_text_cache: std::cell::RefCell<ImeSurroundingTextCache>,
160 caret_blink_timer: Option<fret_runtime::TimerToken>,
161 caret_blink_visible: bool,
162 placeholder: Option<std::sync::Arc<str>>,
163 text_style: TextStyle,
164 wrap: TextWrap,
165 min_height: Px,
166 style: TextAreaStyle,
167 style_override: bool,
168 last_theme_revision: Option<u64>,
169 text_style_override: bool,
170 last_text_style_theme_revision: Option<u64>,
171
172 blob: Option<fret_core::TextBlobId>,
173 metrics: Option<TextMetrics>,
174 placeholder_blob: Option<fret_core::TextBlobId>,
175 placeholder_metrics: Option<TextMetrics>,
176 pending_release: Vec<fret_core::TextBlobId>,
177 prepared_key: Option<PreparedKey>,
178 placeholder_prepared_key: Option<PreparedKey>,
179 text_dirty: bool,
180 show_scrollbar: bool,
181
182 offset_x: Px,
183 offset_y: Px,
184 scrollbar_width: Px,
185 dragging_thumb: bool,
186 drag_pointer_start_y: Px,
187 drag_offset_start_y: Px,
188 last_content_height: Px,
189 last_content_width: Px,
190 last_viewport_height: Px,
191
192 preedit: String,
193 preedit_cursor: Option<(usize, usize)>,
194 preedit_rects: Vec<Rect>,
195 ime_replace_range: Option<(usize, usize)>,
196
197 caret: usize,
198 selection_anchor: usize,
199 affinity: CaretAffinity,
200 preferred_x: Option<Px>,
201 ensure_caret_visible: bool,
202 selection_rects: Vec<Rect>,
203 last_bounds: Rect,
204 last_sent_cursor: Option<Rect>,
205 ime_deduper: crate::text_edit::ime::Deduper,
206 pending_clipboard_token: Option<fret_runtime::ClipboardToken>,
207 pending_primary_selection_token: Option<fret_runtime::ClipboardToken>,
208
209 selection_dragging: bool,
210 last_pointer_pos: Option<Point>,
211 selection_autoscroll_timer: Option<fret_runtime::TimerToken>,
212}
213
214impl Default for TextArea {
215 fn default() -> Self {
216 Self {
217 enabled: true,
218 focusable: true,
219 focus_ring_always_paint: false,
220 text: String::new(),
221 base_text_revision: 0,
222 ime_surrounding_text_cache: std::cell::RefCell::default(),
223 caret_blink_timer: None,
224 caret_blink_visible: true,
225 placeholder: None,
226 text_style: TextStyle {
227 font: fret_core::FontId::default(),
228 size: Px(13.0),
229 ..Default::default()
230 },
231 wrap: TextWrap::Word,
232 min_height: Px(0.0),
233 style: TextAreaStyle::default(),
234 style_override: false,
235 last_theme_revision: None,
236 text_style_override: false,
237 last_text_style_theme_revision: None,
238 blob: None,
239 metrics: None,
240 placeholder_blob: None,
241 placeholder_metrics: None,
242 pending_release: Vec::new(),
243 prepared_key: None,
244 placeholder_prepared_key: None,
245 text_dirty: true,
246 show_scrollbar: false,
247 offset_x: Px(0.0),
248 offset_y: Px(0.0),
249 scrollbar_width: Px(10.0),
250 dragging_thumb: false,
251 drag_pointer_start_y: Px(0.0),
252 drag_offset_start_y: Px(0.0),
253 last_content_height: Px(0.0),
254 last_content_width: Px(0.0),
255 last_viewport_height: Px(0.0),
256 preedit: String::new(),
257 preedit_cursor: None,
258 preedit_rects: Vec::new(),
259 ime_replace_range: None,
260 caret: 0,
261 selection_anchor: 0,
262 affinity: CaretAffinity::Downstream,
263 preferred_x: None,
264 ensure_caret_visible: true,
265 selection_rects: Vec::new(),
266 last_bounds: Rect::default(),
267 last_sent_cursor: None,
268 ime_deduper: crate::text_edit::ime::Deduper::default(),
269 pending_clipboard_token: None,
270 pending_primary_selection_token: None,
271 selection_dragging: false,
272 last_pointer_pos: None,
273 selection_autoscroll_timer: None,
274 }
275 }
276}
277
278impl TextArea {
279 pub fn new(text: impl Into<String>) -> Self {
280 Self::default().with_text(text)
281 }
282
283 pub fn set_focus_ring_always_paint(&mut self, always_paint: bool) {
284 self.focus_ring_always_paint = always_paint;
285 }
286
287 pub fn set_placeholder(&mut self, placeholder: Option<std::sync::Arc<str>>) {
288 if self.placeholder == placeholder {
289 return;
290 }
291 self.placeholder = placeholder;
292 self.queue_release_placeholder_blob();
293 }
294
295 pub fn set_enabled(&mut self, enabled: bool) {
296 self.enabled = enabled;
297 }
298
299 pub fn set_focusable(&mut self, focusable: bool) {
300 self.focusable = focusable;
301 }
302
303 pub fn text(&self) -> &str {
304 &self.text
305 }
306
307 pub fn set_text(&mut self, text: impl Into<String>) {
308 let next = text.into();
309 if self.text == next {
310 return;
311 }
312 self.text = next;
313 self.base_text_revision = self.base_text_revision.wrapping_add(1);
314 self.caret = self.text.len();
315 self.selection_anchor = self.caret;
316 self.ensure_caret_visible = true;
317 self.preedit.clear();
318 self.preedit_cursor = None;
319 self.ime_replace_range = None;
320 self.ime_deduper = crate::text_edit::ime::Deduper::default();
321 self.text_dirty = true;
322 self.preferred_x = None;
323 }
324
325 pub fn with_text(mut self, text: impl Into<String>) -> Self {
326 self.set_text(text);
327 self
328 }
329
330 pub fn with_text_style(mut self, style: TextStyle) -> Self {
331 self.text_style = style;
332 self.text_style_override = true;
333 self.last_text_style_theme_revision = None;
334 self
335 }
336
337 pub fn with_wrap(mut self, wrap: TextWrap) -> Self {
338 self.wrap = wrap;
339 self
340 }
341
342 pub fn with_min_height(mut self, min_height: Px) -> Self {
343 self.min_height = min_height;
344 self
345 }
346
347 pub fn with_style(mut self, style: TextAreaStyle) -> Self {
348 self.style = style;
349 self.style_override = true;
350 self
351 }
352
353 fn sync_style_from_theme(&mut self, theme: &Theme) {
354 self.scrollbar_width = theme.metric_token("metric.scrollbar.width");
355
356 let rev = theme.revision();
357
358 if !self.style_override && self.last_theme_revision != Some(rev) {
359 self.last_theme_revision = Some(rev);
360 self.style.padding_x = theme.metric_token("metric.padding.md");
361 self.style.padding_y = theme.metric_token("metric.padding.md");
362 self.style.background = theme.color(ThemeColorKey::Card);
363 self.style.border_color = theme.color(ThemeColorKey::Border);
364 self.style.border_color_focused = self.style.border_color;
365 self.style.focus_ring = None;
369 self.style.corner_radii = Corners::all(theme.metric_token("metric.radius.md"));
370 self.style.text_color = theme.color(ThemeColorKey::Foreground);
371 self.style.placeholder_color = theme.color_token("muted-foreground");
372 self.style.selection_color = theme.color_token("selection.background");
373 self.style.caret_color = theme.color(ThemeColorKey::Foreground);
374 self.style.preedit_bg_color = Color {
375 a: 0.22,
376 ..theme.color_token("selection.background")
377 };
378 self.style.preedit_underline_color = theme.color(ThemeColorKey::Primary);
379 }
380
381 if !self.text_style_override && self.last_text_style_theme_revision != Some(rev) {
382 self.last_text_style_theme_revision = Some(rev);
383 let next_size = theme.metric(ThemeMetricKey::FontSize);
384 let mut changed = false;
385 if self.text_style.size != next_size {
386 self.text_style.size = next_size;
387 changed = true;
388 }
389
390 let (base_size, base_line_height) = match self.text_style.font {
391 fret_core::FontId::Monospace => (
392 theme.metric(ThemeMetricKey::MonoFontSize),
393 theme.metric(ThemeMetricKey::MonoFontLineHeight),
394 ),
395 _ => (
396 theme.metric(ThemeMetricKey::FontSize),
397 theme.metric(ThemeMetricKey::FontLineHeight),
398 ),
399 };
400
401 let base_size_px = base_size.0;
402 let base_line_height_px = base_line_height.0;
403 let ratio = if base_size_px.is_finite()
404 && base_line_height_px.is_finite()
405 && base_size_px > 0.0
406 && base_line_height_px > 0.0
407 {
408 base_line_height_px / base_size_px
409 } else {
410 1.25
411 };
412 let size_px = self.text_style.size.0.max(0.0);
413 let next_line_height = Px((size_px * ratio).max(size_px));
414
415 if self.text_style.line_height != Some(next_line_height) {
416 self.text_style.line_height = Some(next_line_height);
417 changed = true;
418 }
419
420 if changed {
421 self.text_dirty = true;
422 self.prepared_key = None;
423 if let Some(blob) = self.blob.take() {
424 self.pending_release.push(blob);
425 }
426 self.metrics = None;
427 }
428 }
429 }
430
431 pub fn offset_y(&self) -> Px {
432 self.offset_y
433 }
434
435 fn clear_preedit(&mut self) {
436 if self.preedit.is_empty() && self.preedit_cursor.is_none() {
437 return;
438 }
439 crate::text_edit::ime::clear_state(
440 &mut self.preedit,
441 &mut self.preedit_cursor,
442 &mut self.ime_replace_range,
443 );
444 self.affinity = CaretAffinity::Downstream;
445 self.text_dirty = true;
446 }
447
448 fn is_ime_composing(&self) -> bool {
449 crate::text_edit::ime::is_composing(&self.preedit, self.preedit_cursor)
450 }
451
452 fn preedit_cursor_end(&self) -> usize {
453 crate::text_edit::ime::preedit_cursor_end(&self.preedit, self.preedit_cursor)
454 }
455
456 fn layout_text(&self) -> Option<String> {
457 if self.preedit.is_empty() {
458 return None;
459 }
460 crate::text_edit::ime::compose_text_at_caret(&self.text, self.caret, &self.preedit)
461 }
462
463 fn caret_display_index(&self) -> usize {
464 crate::text_edit::ime::caret_display_index(self.caret, &self.preedit, self.preedit_cursor)
465 }
466
467 fn map_display_index_to_base(&self, display_index: usize) -> usize {
468 crate::text_edit::ime::display_to_base_index(self.caret, self.preedit.len(), display_index)
469 }
470
471 fn content_bounds(&self) -> Rect {
472 let scrollbar_w = self.scrollbar_width;
473 let inner = self.inner_bounds();
474 if self.last_content_height.0 > self.last_viewport_height.0 {
475 Rect::new(
476 inner.origin,
477 Size::new(
478 Px((inner.size.width.0 - scrollbar_w.0).max(0.0)),
479 inner.size.height,
480 ),
481 )
482 } else {
483 inner
484 }
485 }
486
487 fn selection_range(&self) -> (usize, usize) {
488 crate::text_edit::buffer::selection_range(self.selection_anchor, self.caret)
489 }
490
491 fn edit_state(&mut self) -> crate::text_edit::state::TextEditState<'_> {
492 crate::text_edit::state::TextEditState::new(
493 &mut self.text,
494 &mut self.caret,
495 &mut self.selection_anchor,
496 &mut self.preedit,
497 &mut self.preedit_cursor,
498 &mut self.ime_replace_range,
499 )
500 }
501
502 fn bump_base_text_revision(&mut self) {
503 self.base_text_revision = self.base_text_revision.wrapping_add(1);
504 }
505
506 fn delete_selection_if_any(&mut self) -> bool {
507 if !self.edit_state().delete_selection_if_any() {
508 return false;
509 }
510 self.bump_base_text_revision();
511 self.clear_preedit();
512 self.affinity = CaretAffinity::Downstream;
513 self.text_dirty = true;
514 true
515 }
516
517 fn replace_selection_changed(&mut self, insert: &str) -> bool {
518 let changed = self.edit_state().replace_selection(insert);
519 if !changed {
520 return false;
521 }
522 self.bump_base_text_revision();
523 self.clear_preedit();
524 self.affinity = CaretAffinity::Downstream;
525 self.text_dirty = true;
526 true
527 }
528
529 fn replace_selection(&mut self, insert: &str) {
530 let _ = self.replace_selection_changed(insert);
531 }
532
533 fn queue_release_blob(&mut self) {
534 if let Some(blob) = self.blob.take() {
535 self.pending_release.push(blob);
536 }
537 self.prepared_key = None;
538 }
539
540 fn queue_release_placeholder_blob(&mut self) {
541 if let Some(blob) = self.placeholder_blob.take() {
542 self.pending_release.push(blob);
543 }
544 self.placeholder_metrics = None;
545 self.placeholder_prepared_key = None;
546 }
547
548 fn flush_pending_releases(&mut self, services: &mut dyn fret_core::UiServices) {
549 for blob in self.pending_release.drain(..) {
550 services.text().release(blob);
551 }
552 }
553
554 fn request_clipboard_paste<H: UiHost>(&mut self, cx: &mut CommandCx<'_, H>) -> bool {
555 let Some(window) = cx.window else {
556 return true;
557 };
558 let token = cx.app.next_clipboard_token();
559 self.pending_clipboard_token = Some(token);
560 cx.app
561 .push_effect(Effect::ClipboardReadText { window, token });
562 true
563 }
564
565 fn request_primary_selection_paste<H: UiHost>(&mut self, cx: &mut CommandCx<'_, H>) -> bool {
566 let Some(window) = cx.window else {
567 return true;
568 };
569 let token = cx.app.next_clipboard_token();
570 self.pending_primary_selection_token = Some(token);
571 cx.app
572 .push_effect(Effect::PrimarySelectionGetText { window, token });
573 true
574 }
575
576 fn max_offset(&self) -> Px {
577 Px((self.last_content_height.0 - self.last_viewport_height.0).max(0.0))
578 }
579
580 fn clamp_offset(&mut self, content_height: Px, viewport_height: Px) {
581 let max = Px((content_height.0 - viewport_height.0).max(0.0));
582 self.offset_y = Px(self.offset_y.0.clamp(0.0, max.0));
583 }
584
585 fn apply_basic_command(
586 &mut self,
587 command: &str,
588 is_ime_composing: bool,
589 boundary_mode: fret_runtime::TextBoundaryMode,
590 ) -> crate::text_edit::commands::Outcome {
591 let outcome = crate::text_edit::commands::apply_basic(
592 &mut self.edit_state(),
593 command,
594 is_ime_composing,
595 boundary_mode,
596 );
597 if outcome.invalidate_layout {
598 self.bump_base_text_revision();
599 }
600 outcome
601 }
602
603 fn apply_multiline_ui_delta(
604 &mut self,
605 cx: &mut impl TextAreaUiCx,
606 delta: crate::text_edit::commands::MultilineUiDelta,
607 ) {
608 if !delta.handled {
609 return;
610 }
611
612 if delta.clear_preedit {
613 self.clear_preedit();
614 }
615 if delta.text_dirty {
616 self.text_dirty = true;
617 }
618 if delta.reset_affinity {
619 self.affinity = CaretAffinity::Downstream;
620 }
621 if delta.ensure_caret_visible {
622 self.ensure_caret_visible = true;
623 }
624
625 if delta.invalidate_layout {
626 cx.invalidate_self(Invalidation::Layout);
627 cx.request_redraw();
628 } else if delta.invalidate_paint {
629 cx.invalidate_self(Invalidation::Paint);
630 cx.request_redraw();
631 }
632 }
633
634 fn nav_paint_delta() -> crate::text_edit::commands::MultilineUiDelta {
635 crate::text_edit::commands::MultilineUiDelta {
636 handled: true,
637 invalidate_paint: true,
638 ensure_caret_visible: true,
639 ..Default::default()
640 }
641 }
642
643 fn edit_layout_delta(clear_preedit: bool) -> crate::text_edit::commands::MultilineUiDelta {
644 crate::text_edit::commands::MultilineUiDelta {
645 handled: true,
646 invalidate_layout: true,
647 clear_preedit,
648 text_dirty: true,
649 reset_affinity: true,
650 ensure_caret_visible: true,
651 ..Default::default()
652 }
653 }
654
655 fn scrollbar_geometry(&self, bounds: Rect) -> Option<(Rect, Rect)> {
656 let viewport_h = self.last_viewport_height;
657 if viewport_h.0 <= 0.0 {
658 return None;
659 }
660
661 let content_h = self.last_content_height;
662 if content_h.0 <= viewport_h.0 {
663 return None;
664 }
665
666 let w = self.scrollbar_width;
667 let track = Rect::new(
668 fret_core::Point::new(
669 Px(bounds.origin.x.0 + bounds.size.width.0 - w.0),
670 bounds.origin.y,
671 ),
672 Size::new(w, bounds.size.height),
673 );
674
675 let ratio = (viewport_h.0 / content_h.0).clamp(0.0, 1.0);
676 let min_thumb = 24.0;
677 let thumb_h = Px((viewport_h.0 * ratio).max(min_thumb).min(viewport_h.0));
678
679 let max_offset = self.max_offset().0;
680 let t = if max_offset <= 0.0 {
681 0.0
682 } else {
683 (self.offset_y.0 / max_offset).clamp(0.0, 1.0)
684 };
685 let travel = (viewport_h.0 - thumb_h.0).max(0.0);
686 let thumb_y = Px(track.origin.y.0 + travel * t);
687
688 let thumb = Rect::new(
689 fret_core::Point::new(track.origin.x, thumb_y),
690 Size::new(w, thumb_h),
691 );
692
693 Some((track, thumb))
694 }
695
696 fn set_offset_from_thumb_y(&mut self, bounds: Rect, thumb_top_y: Px) {
697 let Some((track, thumb)) = self.scrollbar_geometry(bounds) else {
698 return;
699 };
700
701 let viewport_h = self.last_viewport_height.0;
702 let travel = (viewport_h - thumb.size.height.0).max(0.0);
703 if travel <= 0.0 {
704 self.offset_y = Px(0.0);
705 return;
706 }
707
708 let t = ((thumb_top_y.0 - track.origin.y.0) / travel).clamp(0.0, 1.0);
709 let max = self.max_offset().0;
710 self.offset_y = Px(max * t);
711 }
712
713 fn inner_bounds(&self) -> Rect {
714 let px = self.style.padding_x;
715 let py = self.style.padding_y;
716 Rect::new(
717 fret_core::Point::new(
718 self.last_bounds.origin.x + px,
719 self.last_bounds.origin.y + py,
720 ),
721 Size::new(
722 Px((self.last_bounds.size.width.0 - px.0 * 2.0).max(0.0)),
723 Px((self.last_bounds.size.height.0 - py.0 * 2.0).max(0.0)),
724 ),
725 )
726 }
727
728 fn set_caret_from_point<H: UiHost>(
729 &mut self,
730 cx: &mut EventCx<'_, H>,
731 point: fret_core::Point,
732 ) {
733 let Some(blob) = self.blob else {
734 return;
735 };
736 let hit = cx.services.hit_test_point(blob, point);
737 if self.preedit.is_empty() {
738 self.caret = hit.index;
739 self.affinity = hit.affinity;
740 } else {
741 self.caret = self.map_display_index_to_base(hit.index);
742 self.clear_preedit();
743 self.affinity = CaretAffinity::Downstream;
744 }
745 }
746}