Skip to main content

fret_ui/text/area/
mod.rs

1//! Multiline text area widget (retained) providing IME/caret/selection engine behavior.
2//!
3//! This lives in the runtime crate because it needs platform hooks and hard-to-change editing
4//! semantics (ADR 0012 / ADR 0071).
5use 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    /// Border color applied when the textarea itself is focused and focus-visible is active.
76    ///
77    /// This aligns with shadcn/ui-style `focus-visible:border-ring` outcomes without requiring
78    /// wrapper containers to own the textarea border.
79    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            // Focus ring styling is intentionally component-owned (recipes) rather than
366            // runtime-owned to keep `fret-ui` mechanism-only (ADR 0066). Component libraries can
367            // set `TextAreaStyle.focus_ring` explicitly when desired.
368            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}