Skip to main content

incular_text/
editing.rs

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