Skip to main content

incular_text/
editing.rs

1//! Renderer-independent editing and selection state.
2
3use crate::{TextGeometry, TextStyle};
4use icu_segmenter::GraphemeClusterSegmenter;
5use incular_core::time::Instant;
6use incular_core::{RestorationKey, RestorationScope};
7use std::{cell::RefCell, fmt, ops::Range, rc::Rc};
8
9/// Whether a caret is associated with the leading or trailing edge of a
10/// bidirectional run.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum TextAffinity {
13    #[default]
14    Downstream,
15    Upstream,
16}
17
18/// A half-open UTF-8 byte range. Text engines use byte offsets so ranges can
19/// be passed directly to shaping and grapheme segmentation without lossy
20/// conversions. Call [`Self::clamp_to`] before slicing arbitrary user input.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub struct TextRange {
23    pub start: usize,
24    pub end: usize,
25}
26
27impl TextRange {
28    pub const EMPTY: Self = Self { start: 0, end: 0 };
29
30    #[must_use]
31    pub const fn new(start: usize, end: usize) -> Self {
32        Self { start, end }
33    }
34
35    #[must_use]
36    pub const fn collapsed(offset: usize) -> Self {
37        Self::new(offset, offset)
38    }
39
40    #[must_use]
41    pub const fn is_collapsed(self) -> bool {
42        self.start == self.end
43    }
44
45    #[must_use]
46    pub const fn is_valid(self) -> bool {
47        self.start <= self.end
48    }
49
50    #[must_use]
51    pub const fn len(self) -> usize {
52        self.end.saturating_sub(self.start)
53    }
54
55    #[must_use]
56    pub const fn is_empty(self) -> bool {
57        self.start == self.end
58    }
59
60    #[must_use]
61    pub const fn as_range(self) -> Range<usize> {
62        self.start..self.end
63    }
64
65    /// Clamps the range to a string and moves each endpoint to a valid UTF-8
66    /// boundary. This keeps editing operations panic-free for IME byte offsets.
67    #[must_use]
68    pub fn clamp_to(self, text: &str) -> Self {
69        let start = nearest_char_boundary(text, self.start.min(text.len()));
70        let end = nearest_char_boundary(text, self.end.min(text.len())).max(start);
71        Self::new(start, end)
72    }
73
74    #[must_use]
75    pub fn normalized(self) -> Self {
76        if self.start <= self.end {
77            self
78        } else {
79            Self::new(self.end, self.start)
80        }
81    }
82}
83
84/// A selection preserves directional base/extent while exposing a normalized
85/// range for text replacement.
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
87pub struct TextSelection {
88    pub base: usize,
89    pub extent: usize,
90}
91
92impl TextSelection {
93    #[must_use]
94    pub const fn new(base: usize, extent: usize) -> Self {
95        Self { base, extent }
96    }
97
98    #[must_use]
99    pub const fn collapsed(offset: usize) -> Self {
100        Self::new(offset, offset)
101    }
102
103    #[must_use]
104    pub const fn start(self) -> usize {
105        if self.base < self.extent {
106            self.base
107        } else {
108            self.extent
109        }
110    }
111
112    #[must_use]
113    pub const fn end(self) -> usize {
114        if self.base > self.extent {
115            self.base
116        } else {
117            self.extent
118        }
119    }
120
121    #[must_use]
122    pub const fn is_collapsed(self) -> bool {
123        self.base == self.extent
124    }
125
126    #[must_use]
127    pub const fn is_forward(self) -> bool {
128        self.base <= self.extent
129    }
130
131    #[must_use]
132    pub const fn range(self) -> TextRange {
133        TextRange::new(self.start(), self.end())
134    }
135
136    /// Clamps both edges into the text at UTF-8 char boundaries,
137    /// snapping down. Char boundaries are deliberately not grapheme
138    /// boundaries: a mid-grapheme edge survives clamping and the caret
139    /// mapping below resolves it to the shaping-cluster edge instead.
140    #[must_use]
141    pub fn clamp_to(self, text: &str) -> Self {
142        let base = nearest_char_boundary(text, self.base.min(text.len()));
143        let extent = nearest_char_boundary(text, self.extent.min(text.len()));
144        Self::new(base, extent)
145    }
146}
147
148/// A composing/marked range supplied by an IME.
149pub type ComposingRange = TextRange;
150
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct TextEditingValue {
153    pub text: String,
154    pub selection: TextSelection,
155    pub composing: Option<ComposingRange>,
156}
157
158impl TextEditingValue {
159    #[must_use]
160    pub fn new(text: impl Into<String>) -> Self {
161        let text = text.into();
162        let caret = text.len();
163        Self {
164            text,
165            selection: TextSelection::collapsed(caret),
166            composing: None,
167        }
168    }
169
170    #[must_use]
171    pub const fn empty() -> Self {
172        Self {
173            text: String::new(),
174            selection: TextSelection::collapsed(0),
175            composing: None,
176        }
177    }
178
179    #[must_use]
180    pub fn with_selection(mut self, selection: TextSelection) -> Self {
181        self.selection = selection.clamp_to(&self.text);
182        self
183    }
184
185    #[must_use]
186    pub fn with_composing(mut self, composing: Option<ComposingRange>) -> Self {
187        self.composing = composing.map(|range| range.clamp_to(&self.text));
188        self
189    }
190
191    #[must_use]
192    pub fn replace(mut self, range: TextRange, replacement: &str) -> Self {
193        let range = range.normalized().clamp_to(&self.text);
194        self.text.replace_range(range.as_range(), replacement);
195        let caret = range.start + replacement.len();
196        self.selection = TextSelection::collapsed(caret);
197        self.composing = None;
198        self
199    }
200}
201
202impl Default for TextEditingValue {
203    fn default() -> Self {
204        Self::empty()
205    }
206}
207
208/// An atomic editing operation, useful for integrating native IMEs and undo
209/// stacks without coupling those systems to a widget.
210#[derive(Clone, Debug, PartialEq, Eq)]
211pub struct TextEditingDelta {
212    pub range: TextRange,
213    pub replacement: String,
214    pub selection: Option<TextSelection>,
215    pub composing: Option<ComposingRange>,
216}
217
218impl TextEditingDelta {
219    #[must_use]
220    pub fn replace(range: TextRange, replacement: impl Into<String>) -> Self {
221        Self {
222            range,
223            replacement: replacement.into(),
224            selection: None,
225            composing: None,
226        }
227    }
228
229    #[must_use]
230    pub fn apply(&self, value: &TextEditingValue) -> TextEditingValue {
231        let mut next = value.clone().replace(self.range, &self.replacement);
232        if let Some(selection) = self.selection {
233            next.selection = selection.clamp_to(&next.text);
234        }
235        next.composing = self.composing.map(|range| range.clamp_to(&next.text));
236        next
237    }
238}
239
240#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
241pub enum SelectionChangedCause {
242    #[default]
243    Unknown,
244    Keyboard,
245    Tap,
246    DoubleTap,
247    LongPress,
248    Drag,
249    Accessibility,
250    Programmatic,
251}
252
253type Listener = Rc<dyn Fn(&TextEditingValue)>;
254type EditTransform = Rc<dyn Fn(&TextEditingValue, &TextEditingValue) -> TextEditingValue>;
255
256#[derive(Default)]
257struct ControllerState {
258    value: TextEditingValue,
259    listeners: Vec<(usize, Listener)>,
260    next_listener: usize,
261    content_revision: u64,
262    visual_revision: u64,
263    caret_reset: Option<Instant>,
264    preferred_caret_x: Option<f32>,
265    caret_affinity: TextAffinity,
266    preedit: Option<String>,
267    preedit_selection: Option<TextRange>,
268    restoration: Option<TextRestoration>,
269    restoration_generation: u64,
270}
271
272#[derive(Clone)]
273struct TextRestoration {
274    scope: RestorationScope,
275    key: RestorationKey,
276}
277
278/// Cloneable editing controller. It owns no platform resources; platform and
279/// widget layers can subscribe to value changes and decide when to repaint.
280#[derive(Clone, Default)]
281pub struct TextEditingController {
282    changes: incular_core::reactivity::DependencySource,
283    transforms: incular_core::reactivity::DependencySource,
284    inner: Rc<RefCell<ControllerState>>,
285}
286
287/// Owns a text-controller listener until dropped.
288#[must_use = "keep the subscription alive to observe changes"]
289pub struct TextEditingSubscription {
290    _subscription: incular_core::reactivity::Subscription,
291}
292
293impl PartialEq for TextEditingController {
294    fn eq(&self, other: &Self) -> bool {
295        Rc::ptr_eq(&self.inner, &other.inner)
296    }
297}
298
299impl fmt::Debug for TextEditingController {
300    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301        formatter
302            .debug_struct("TextEditingController")
303            .field("value", &self.value())
304            .finish()
305    }
306}
307
308impl TextEditingController {
309    #[must_use]
310    pub fn new() -> Self {
311        Self::from_value(TextEditingValue::default())
312    }
313
314    /// Creates a controller initialized with committed text.
315    #[must_use]
316    pub fn with_text(text: impl Into<String>) -> Self {
317        Self::from_value(TextEditingValue::new(text))
318    }
319
320    /// Creates an empty editor and restores its committed state, if present.
321    #[must_use]
322    pub fn restored(scope: RestorationScope, key: RestorationKey) -> Self {
323        let controller = Self::new();
324        controller.bind_restoration(scope, key);
325        controller
326    }
327
328    /// Binds committed text and selection to a stable restoration value.
329    pub fn bind_restoration(&self, scope: RestorationScope, key: RestorationKey) {
330        let restored = scope.get_json(&key).and_then(value_from_json);
331        let changed = restored
332            .as_ref()
333            .is_some_and(|value| *value != self.inner.borrow().value);
334        let mut state = self.inner.borrow_mut();
335        state.restoration = Some(TextRestoration { scope, key });
336        // Restoration is a baseline, not an edit: bump the generation so
337        // history observers rebaseline instead of recording an undo step.
338        state.restoration_generation = state
339            .restoration_generation
340            .checked_add(1)
341            .expect("text restoration generation exhausted");
342        if let Some(value) = restored {
343            state.value = value;
344            state.content_revision = state
345                .content_revision
346                .checked_add(1)
347                .expect("content revision exhausted");
348            state.visual_revision = state
349                .visual_revision
350                .checked_add(1)
351                .expect("visual revision exhausted");
352            state.preedit = None;
353            state.preedit_selection = None;
354            state.caret_reset = None;
355            state.preferred_caret_x = None;
356            state.caret_affinity = TextAffinity::Downstream;
357        }
358        drop(state);
359        if changed {
360            self.notify_listeners();
361        }
362    }
363
364    /// Stops persisting subsequent editor mutations without deleting the saved value.
365    pub fn unbind_restoration(&self) {
366        self.inner.borrow_mut().restoration = None;
367    }
368
369    #[must_use]
370    pub fn from_value(value: TextEditingValue) -> Self {
371        Self {
372            changes: incular_core::reactivity::DependencySource::default(),
373            transforms: incular_core::reactivity::DependencySource::default(),
374            inner: Rc::new(RefCell::new(ControllerState {
375                value,
376                ..ControllerState::default()
377            })),
378        }
379    }
380
381    #[must_use]
382    pub fn value(&self) -> TextEditingValue {
383        self.changes.track();
384        self.inner.borrow().value.clone()
385    }
386
387    /// Runs `f` against the current value without cloning it. Dependency
388    /// tracking matches [`Self::value`]. The controller must not be mutated
389    /// from inside `f`.
390    pub fn with_value<R>(&self, f: impl FnOnce(&TextEditingValue) -> R) -> R {
391        self.changes.track();
392        f(&self.inner.borrow().value)
393    }
394
395    #[must_use]
396    pub fn text(&self) -> String {
397        self.changes.track();
398        self.inner.borrow().value.text.clone()
399    }
400
401    #[must_use]
402    pub fn selection(&self) -> TextSelection {
403        self.changes.track();
404        self.inner.borrow().value.selection
405    }
406
407    #[must_use]
408    pub fn composing(&self) -> Option<ComposingRange> {
409        self.changes.track();
410        self.inner.borrow().value.composing
411    }
412
413    pub fn set_value(&self, value: TextEditingValue) {
414        self.update(value, true);
415        self.inner.borrow_mut().caret_affinity = TextAffinity::Downstream;
416    }
417
418    pub fn set_text(&self, text: impl Into<String>) {
419        self.update(TextEditingValue::new(text), true);
420        self.inner.borrow_mut().caret_affinity = TextAffinity::Downstream;
421    }
422
423    pub fn clear(&self) {
424        self.set_text("");
425    }
426
427    pub fn set_selection(&self, selection: TextSelection) {
428        let mut value = self.value();
429        value.selection = selection.clamp_to(&value.text);
430        self.update(value, false);
431        self.inner.borrow_mut().caret_affinity = TextAffinity::Downstream;
432    }
433
434    /// Sets a selection while preserving which visual edge of a bidi run owns
435    /// a collapsed caret. Native hit testing should use this overload.
436    pub fn set_selection_with_affinity(&self, selection: TextSelection, affinity: TextAffinity) {
437        let mut value = self.value();
438        value.selection = selection.clamp_to(&value.text);
439        self.update(value, false);
440        self.inner.borrow_mut().caret_affinity = affinity;
441    }
442
443    pub fn set_composing(&self, composing: Option<ComposingRange>) {
444        let mut value = self.value();
445        value.composing = composing.map(|range| range.clamp_to(&value.text));
446        self.update(value, false);
447    }
448
449    /// Observes committed editing changes with automatic cleanup on drop.
450    pub fn observe(
451        &self,
452        listener: impl Fn(&TextEditingValue) + 'static,
453    ) -> TextEditingSubscription {
454        let state = Rc::downgrade(&self.inner);
455        TextEditingSubscription {
456            _subscription: self.changes.subscribe((), move || {
457                if let Some(state) = state.upgrade() {
458                    let value = state.borrow().value.clone();
459                    listener(&value);
460                }
461            }),
462        }
463    }
464
465    #[doc(hidden)]
466    pub fn register_edit_transform(
467        &self,
468        transform: impl Fn(&TextEditingValue, &TextEditingValue) -> TextEditingValue + 'static,
469    ) -> incular_core::reactivity::Subscription {
470        let transform: EditTransform = Rc::new(transform);
471        self.transforms.subscribe(transform, || {})
472    }
473
474    /// Low-level manual registration for backend integrations. Prefer `observe`.
475    /// Returns an opaque token accepted by
476    /// [`Self::remove_listener`].
477    pub fn add_listener(&self, listener: impl Fn(&TextEditingValue) + 'static) -> usize {
478        let mut state = self.inner.borrow_mut();
479        let token = state.next_listener;
480        state.next_listener = state
481            .next_listener
482            .checked_add(1)
483            .expect("text editing listener identity space exhausted");
484        state.listeners.push((token, Rc::new(listener)));
485        token
486    }
487
488    pub fn remove_listener(&self, token: usize) -> bool {
489        let mut state = self.inner.borrow_mut();
490        let before = state.listeners.len();
491        state.listeners.retain(|(id, _)| *id != token);
492        before != state.listeners.len()
493    }
494
495    pub fn apply_delta(&self, delta: &TextEditingDelta) {
496        let next = delta.apply(&self.value());
497        self.update(next, true);
498    }
499
500    pub fn replace_selection(&self, replacement: &str) {
501        let value = self.value();
502        let range = value.selection.range();
503        self.update(value.replace(range, replacement), true);
504    }
505
506    pub fn insert_text(&self, text: &str) {
507        self.replace_selection(text);
508    }
509
510    pub fn delete_backward(&self) {
511        self.backspace();
512    }
513
514    pub fn delete_forward(&self) {
515        let value = self.value();
516        let range = if value.selection.is_collapsed() {
517            next_grapheme_boundary(&value.text, value.selection.extent)
518                .map_or(TextRange::collapsed(value.selection.extent), |end| {
519                    TextRange::new(value.selection.extent, end)
520                })
521        } else {
522            value.selection.range()
523        };
524        self.update(value.replace(range, ""), true);
525    }
526
527    /// Alias used by the Widgets editor API.
528    pub fn insert(&self, text: &str) {
529        self.insert_text(text);
530    }
531
532    /// Deletes one grapheme cluster behind the caret, or the active selection.
533    pub fn backspace(&self) {
534        let value = self.value();
535        let range = if value.selection.is_collapsed() {
536            previous_grapheme_boundary(&value.text, value.selection.extent)
537                .map_or(TextRange::collapsed(value.selection.extent), |start| {
538                    TextRange::new(start, value.selection.extent)
539                })
540        } else {
541            value.selection.range()
542        };
543        self.update(value.replace(range, ""), true);
544    }
545
546    /// Deletes one grapheme cluster ahead of the caret, or the active selection.
547    pub fn delete(&self) {
548        self.delete_forward();
549    }
550
551    pub fn move_left(&self, extend: bool) {
552        self.move_cursor(
553            previous_grapheme_boundary(&self.value().text, self.value().selection.extent)
554                .unwrap_or(0),
555            extend,
556        );
557    }
558
559    pub fn move_right(&self, extend: bool) {
560        let value = self.value();
561        self.move_cursor(
562            next_grapheme_boundary(&value.text, value.selection.extent).unwrap_or(value.text.len()),
563            extend,
564        );
565    }
566
567    /// Moves one grapheme in screen/visual order using the bidi caret stops
568    /// of a [`TextGeometry`]. The logical byte offset may move backwards
569    /// while the caret moves right, which is required for right-to-left text.
570    pub fn move_left_visual(&self, layout: &(impl TextGeometry + ?Sized), extend: bool) {
571        self.move_visual(layout, false, extend);
572    }
573
574    /// Moves one grapheme in screen/visual order using the bidi caret stops
575    /// of a [`TextGeometry`].
576    pub fn move_right_visual(&self, layout: &(impl TextGeometry + ?Sized), extend: bool) {
577        self.move_visual(layout, true, extend);
578    }
579
580    pub fn move_home(&self, extend: bool) {
581        self.move_cursor(0, extend);
582    }
583
584    pub fn move_end(&self, extend: bool) {
585        self.move_cursor(self.text().len(), extend);
586    }
587
588    pub fn select_all(&self) {
589        self.set_selection(TextSelection::new(0, self.text().len()));
590    }
591
592    #[must_use]
593    pub fn selected_text(&self) -> String {
594        let value = self.value();
595        let range = value.selection.range();
596        value.text[range.as_range()].to_owned()
597    }
598
599    /// Sets transient IME preedit text without changing committed content.
600    pub fn set_preedit(&self, text: impl Into<String>, selection: Option<TextRange>) {
601        incular_core::reactivity::assert_mutation_allowed();
602        let text = text.into();
603        let mut state = self.inner.borrow_mut();
604        state.preedit_selection = selection.map(|range| range.clamp_to(&text));
605        state.preedit = (!text.is_empty()).then_some(text);
606        state.visual_revision = state
607            .visual_revision
608            .checked_add(1)
609            .expect("visual revision exhausted");
610        drop(state);
611        self.notify_listeners();
612    }
613
614    pub fn commit_preedit(&self, text: &str) {
615        let value = self.value();
616        let range = value.selection.range();
617        self.update_committed(value.replace(range, text), true, true);
618    }
619
620    pub fn clear_preedit(&self) {
621        incular_core::reactivity::assert_mutation_allowed();
622        let mut state = self.inner.borrow_mut();
623        if state.preedit.take().is_some() {
624            state.preedit_selection = None;
625            state.visual_revision = state
626                .visual_revision
627                .checked_add(1)
628                .expect("visual revision exhausted");
629            drop(state);
630            self.notify_listeners();
631        }
632    }
633
634    #[must_use]
635    pub fn preedit(&self) -> Option<String> {
636        self.inner.borrow().preedit.clone()
637    }
638
639    #[must_use]
640    pub fn preedit_selection(&self) -> Option<TextRange> {
641        self.inner.borrow().preedit_selection
642    }
643
644    /// Returns committed and visual revisions used by retained repaint scheduling.
645    #[doc(hidden)]
646    #[must_use]
647    pub fn revisions(&self) -> (u64, u64) {
648        let state = self.inner.borrow();
649        (state.content_revision, state.visual_revision)
650    }
651
652    /// Restoration baseline generation; history rebaselines when observed.
653    #[doc(hidden)]
654    #[must_use]
655    pub fn restoration_generation(&self) -> u64 {
656        self.inner.borrow().restoration_generation
657    }
658
659    /// Restarts the caret blink after an editing interaction.
660    #[doc(hidden)]
661    pub fn reset_caret(&self, now: Instant) {
662        let mut state = self.inner.borrow_mut();
663        state.caret_reset = Some(now);
664        state.visual_revision = state
665            .visual_revision
666            .checked_add(1)
667            .expect("visual revision exhausted");
668    }
669
670    /// Returns whether the caret should be painted at `now`.
671    #[doc(hidden)]
672    #[must_use]
673    pub fn caret_visible(&self, now: Instant) -> bool {
674        self.caret_opacity(now, false) > 0.0
675    }
676
677    /// The caret's opacity at `now` since the blink last restarted. Without
678    /// `animates` the caret is on, then off, for `_kCursorBlinkHalfPeriod`
679    /// (500 ms) each; with it, it follows Flutter's
680    /// `_KeyFrame.iOSBlinkingCaretKeyFrames` (iOS 15.4 UIKit) once a second
681    /// (`EditableText.cursorOpacityAnimates`).
682    #[must_use]
683    pub fn caret_opacity(&self, now: Instant, animates: bool) -> f32 {
684        // (time in seconds, opacity) key frames, held until the next one.
685        const IOS_BLINKING_CARET: [(f32, f32); 11] = [
686            (0.0, 1.0),
687            (0.5, 1.0),
688            (0.5375, 0.75),
689            (0.575, 0.5),
690            (0.6125, 0.25),
691            (0.65, 0.0),
692            (0.85, 0.0),
693            (0.8875, 0.25),
694            (0.925, 0.5),
695            (0.9625, 0.75),
696            (1.0, 1.0),
697        ];
698        let Some(start) = self.inner.borrow().caret_reset else {
699            return 0.0;
700        };
701        let elapsed = now.checked_duration_since(start).unwrap_or_default();
702        if !animates {
703            return if (elapsed.as_millis() / 500).is_multiple_of(2) {
704                1.0
705            } else {
706                0.0
707            };
708        }
709        // The simulation restarts when it completes, every second.
710        let time = (elapsed.as_micros() % 1_000_000) as f32 / 1_000_000.0;
711        IOS_BLINKING_CARET
712            .iter()
713            .rev()
714            .find(|(at, _)| *at <= time)
715            .map_or(1.0, |(_, opacity)| *opacity)
716    }
717
718    #[doc(hidden)]
719    #[must_use]
720    pub fn preferred_caret_x(&self) -> Option<f32> {
721        self.inner.borrow().preferred_caret_x
722    }
723
724    /// Retains the horizontal column used by vertical movement without
725    /// changing the editing value.
726    #[doc(hidden)]
727    pub fn set_preferred_caret_x(&self, x: f32) {
728        self.inner.borrow_mut().preferred_caret_x = x.is_finite().then_some(x);
729    }
730
731    /// Returns the visual affinity of the collapsed caret.
732    #[must_use]
733    pub fn caret_affinity(&self) -> TextAffinity {
734        self.inner.borrow().caret_affinity
735    }
736
737    #[doc(hidden)]
738    pub fn move_cursor(&self, target: usize, extend: bool) {
739        self.move_cursor_impl(target, extend, None, TextAffinity::Downstream);
740    }
741
742    #[doc(hidden)]
743    pub fn move_cursor_with_affinity(&self, target: usize, affinity: TextAffinity, extend: bool) {
744        self.move_cursor_impl(target, extend, None, affinity);
745    }
746
747    fn move_cursor_impl(
748        &self,
749        target: usize,
750        extend: bool,
751        preferred_x: Option<f32>,
752        affinity: TextAffinity,
753    ) {
754        let mut value = self.value();
755        let target = nearest_char_boundary(&value.text, target.min(value.text.len()));
756        value.selection = if extend {
757            TextSelection::new(value.selection.base, target)
758        } else {
759            TextSelection::collapsed(target)
760        };
761        self.update(value, false);
762        let mut state = self.inner.borrow_mut();
763        state.preferred_caret_x = preferred_x;
764        state.caret_affinity = affinity;
765    }
766
767    fn move_visual(&self, layout: &(impl TextGeometry + ?Sized), right: bool, extend: bool) {
768        let value = self.value();
769        let extent = value.selection.extent;
770        // An unextended arrow first collapses an active selection, matching
771        // native editors and avoiding a surprising extra visual step.
772        if !extend && !value.selection.is_collapsed() {
773            let target = if right {
774                value.selection.end()
775            } else {
776                value.selection.start()
777            };
778            self.move_cursor_with_affinity(target, TextAffinity::Downstream, false);
779            return;
780        }
781        let line = layout
782            .lines()
783            .position(|line| extent >= line.start() && extent <= line.end())
784            .unwrap_or_else(|| layout.line_count().saturating_sub(1));
785        let stops = layout.line_caret_positions(line);
786        if stops.is_empty() {
787            return;
788        }
789        let affinity = self.caret_affinity();
790        let current = stops
791            .iter()
792            .position(|stop| stop.offset == extent && stop.affinity == affinity)
793            .or_else(|| stops.iter().position(|stop| stop.offset == extent))
794            .unwrap_or_else(|| {
795                if right {
796                    0
797                } else {
798                    stops.len().saturating_sub(1)
799                }
800            });
801        let target = if right {
802            stops.get(current + 1).copied().or_else(|| {
803                (line + 1..layout.line_count())
804                    .find_map(|next_line| layout.line_caret_positions(next_line).first().copied())
805            })
806        } else {
807            current
808                .checked_sub(1)
809                .and_then(|index| stops.get(index).copied())
810                .or_else(|| {
811                    (0..line).rev().find_map(|previous_line| {
812                        layout.line_caret_positions(previous_line).last().copied()
813                    })
814                })
815        };
816        let Some(target) = target else {
817            return;
818        };
819        self.move_cursor_with_affinity(target.offset, target.affinity, extend);
820    }
821
822    fn update(&self, value: TextEditingValue, content_changed: bool) {
823        self.update_committed(value, content_changed, false);
824    }
825
826    fn update_committed(&self, value: TextEditingValue, content_changed: bool, end_preedit: bool) {
827        incular_core::reactivity::assert_mutation_allowed();
828        let (old, revision) = {
829            let state = self.inner.borrow();
830            (state.value.clone(), state.visual_revision)
831        };
832        let value = {
833            let transforms = self.transforms.subscribers::<EditTransform>();
834            let mut next = value;
835            for transform in transforms {
836                let _guard = incular_core::reactivity::ReadOnlyGuard::enter();
837                next = transform(&old, &next);
838                if self.inner.borrow().visual_revision != revision {
839                    return;
840                }
841            }
842            next.selection = next.selection.clamp_to(&next.text);
843            next.composing = next.composing.map(|range| range.clamp_to(&next.text));
844            next
845        };
846        let listeners = {
847            let mut state = self.inner.borrow_mut();
848            if state.value == value && !(end_preedit && state.preedit.is_some()) {
849                return;
850            }
851            state.value = value;
852            if content_changed {
853                state.content_revision = state
854                    .content_revision
855                    .checked_add(1)
856                    .expect("content revision exhausted");
857            }
858            state.visual_revision = state
859                .visual_revision
860                .checked_add(1)
861                .expect("visual revision exhausted");
862            state.preedit = None;
863            state.preedit_selection = None;
864            state.preferred_caret_x = None;
865            state
866                .listeners
867                .iter()
868                .map(|(_, listener)| listener.clone())
869                .collect::<Vec<_>>()
870        };
871        let value = self.inner.borrow().value.clone();
872        for listener in listeners {
873            listener(&value);
874        }
875        self.persist_restoration();
876        self.changes.notify();
877    }
878
879    fn notify_listeners(&self) {
880        let (value, listeners) = {
881            let state = self.inner.borrow();
882            (
883                state.value.clone(),
884                state
885                    .listeners
886                    .iter()
887                    .map(|(_, listener)| listener.clone())
888                    .collect::<Vec<_>>(),
889            )
890        };
891        for listener in listeners {
892            listener(&value);
893        }
894        self.changes.notify();
895    }
896
897    fn persist_restoration(&self) {
898        let (restoration, value) = {
899            let state = self.inner.borrow();
900            let Some(restoration) = state.restoration.clone() else {
901                return;
902            };
903            (restoration, value_to_json(&state.value))
904        };
905        restoration.scope.set_json(&restoration.key, value);
906    }
907}
908
909fn value_to_json(value: &TextEditingValue) -> serde_json::Value {
910    serde_json::json!({
911        "text": value.text,
912        "selection": {
913            "base": value.selection.base,
914            "extent": value.selection.extent,
915        },
916    })
917}
918
919fn value_from_json(value: serde_json::Value) -> Option<TextEditingValue> {
920    let object = value.as_object()?;
921    let text = object.get("text")?.as_str()?.to_owned();
922    let selection = object
923        .get("selection")
924        .and_then(serde_json::Value::as_object)
925        .and_then(|selection| {
926            let base = usize::try_from(selection.get("base")?.as_u64()?).ok()?;
927            let extent = usize::try_from(selection.get("extent")?.as_u64()?).ok()?;
928            Some(TextSelection::new(base, extent))
929        })
930        .unwrap_or_else(|| TextSelection::collapsed(text.len()));
931    Some(TextEditingValue::new(text.clone()).with_selection(selection.clamp_to(&text)))
932}
933
934/// A renderer-neutral editable text description.
935#[derive(Clone, Debug)]
936pub struct EditableText {
937    pub controller: TextEditingController,
938    pub style: TextStyle,
939    pub read_only: bool,
940    pub obscure_text: bool,
941    pub max_lines: Option<usize>,
942}
943
944impl EditableText {
945    #[must_use]
946    pub fn new(controller: TextEditingController) -> Self {
947        Self {
948            controller,
949            style: TextStyle::default(),
950            read_only: false,
951            obscure_text: false,
952            max_lines: Some(1),
953        }
954    }
955
956    #[must_use]
957    pub fn style(mut self, style: TextStyle) -> Self {
958        self.style = style;
959        self
960    }
961
962    #[must_use]
963    pub fn read_only(mut self, read_only: bool) -> Self {
964        self.read_only = read_only;
965        self
966    }
967
968    #[must_use]
969    pub fn obscure_text(mut self, obscure_text: bool) -> Self {
970        self.obscure_text = obscure_text;
971        self
972    }
973
974    #[must_use]
975    pub fn max_lines(mut self, max_lines: Option<usize>) -> Self {
976        self.max_lines = max_lines;
977        self
978    }
979}
980
981/// Convenience text-field configuration built on [`EditableText`].
982#[derive(Clone, Debug)]
983pub struct TextField {
984    pub editable: EditableText,
985    pub placeholder: String,
986}
987
988impl TextField {
989    #[must_use]
990    pub fn new(controller: TextEditingController) -> Self {
991        Self {
992            editable: EditableText::new(controller),
993            placeholder: String::new(),
994        }
995    }
996
997    #[must_use]
998    pub fn style(mut self, style: TextStyle) -> Self {
999        self.editable = self.editable.style(style);
1000        self
1001    }
1002
1003    #[must_use]
1004    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1005        self.placeholder = placeholder.into();
1006        self
1007    }
1008
1009    #[must_use]
1010    pub fn read_only(mut self, read_only: bool) -> Self {
1011        self.editable = self.editable.read_only(read_only);
1012        self
1013    }
1014}
1015
1016fn nearest_char_boundary(text: &str, offset: usize) -> usize {
1017    if text.is_char_boundary(offset) {
1018        return offset;
1019    }
1020    let mut boundary = offset;
1021    while boundary > 0 && !text.is_char_boundary(boundary) {
1022        boundary -= 1;
1023    }
1024    boundary
1025}
1026
1027fn previous_grapheme_boundary(text: &str, offset: usize) -> Option<usize> {
1028    let offset = nearest_char_boundary(text, offset.min(text.len()));
1029    (offset > 0).then(|| {
1030        GraphemeClusterSegmenter::new()
1031            .segment_str(text)
1032            .take_while(|index| *index < offset)
1033            .last()
1034            .unwrap_or(0)
1035    })
1036}
1037
1038fn next_grapheme_boundary(text: &str, offset: usize) -> Option<usize> {
1039    let offset = nearest_char_boundary(text, offset.min(text.len()));
1040    (offset < text.len()).then(|| {
1041        GraphemeClusterSegmenter::new()
1042            .segment_str(text)
1043            .find(|index| *index > offset)
1044            .unwrap_or(text.len())
1045    })
1046}