Skip to main content

teksilo_core/
text_surface.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TextSurface` — "the widget the caret is in", as an application sees it.
5//!
6//! # Why the framework has to answer this
7//!
8//! An application that wants **one** Undo command — one chord, one menu row,
9//! routed to whatever the writer is actually editing — handles the
10//! [`edit.undo`](crate::shortcut::StandardEdit::Undo) intent. From the chord,
11//! the walk starts at the focused widget, so a text widget's own action
12//! answers before the application's. Two routes do not pass through the
13//! widget at all, and there the application owes it an answer: a menu row or
14//! a toolbar button, whose intent starts in the chrome, and a widget that
15//! refused its history (a forward-only writing mode), which passes the intent
16//! on rather than letting it reach the application's own history.
17//!
18//! It cannot produce one by itself. It can recognise the surfaces it built and
19//! kept a handle on, and it is blind to the rest: a rename box in a table cell,
20//! a search field, a text input inside a dialog it did not write. An application
21//! that guesses gets it exactly backwards — Ctrl+Z in a rename box undoes
22//! something else entirely, which is worse than not having the feature.
23//!
24//! Maintaining a list of every text widget in the application is not a fix: it
25//! is correct on the day it is written and wrong the first time someone adds a
26//! field. The framework already knows which widget has focus and which widgets
27//! edit text, so it is the only place the question can be answered *completely*.
28//!
29//! # What implementors get
30//!
31//! Every text widget registers itself with [`BuildContext::register_text_surface`],
32//! and the registration is torn down with the widget exactly as a global action
33//! is. A host then asks [`WidgetTree::focused_text_surface`] and either drives it
34//! or — knowing one exists — steps aside and lets the widget keep its own keys.
35//!
36//! # An empty history can pass the command on
37//!
38//! By default the focused text widget answers Undo and Redo whether or not it
39//! has anything to step through, so an application's own `edit.undo` action is
40//! reached only from a menu, or where no text widget has focus. An application
41//! whose Undo deliberately falls back to a wider history when the caret's own
42//! is exhausted — and says so, in a menu row naming what it will undo — turns
43//! on [`TextSurfaces::set_empty_history_passes_on`]: a text widget with nothing
44//! to undo then declines the command, and the intent climbs to the
45//! application's action exactly as it does from a refused history. Off by
46//! default, because an application that never announces the fallback would
47//! have Ctrl+Z in an empty search field undo something the user is not looking
48//! at.
49//!
50//! # What the platform's text input system reads
51//!
52//! A surface can also describe its document to the platform's text input
53//! system (where the selection is, what the text is, where a range is drawn),
54//! through [`TextSurface::input_document`]. A platform bridge takes the
55//! window's [`TextSurfaces`] with [`WidgetTree::text_surfaces`] and asks
56//! [`TextSurfaces::with_focused_input_document`]. See
57//! [`crate::text_input_document`].
58//!
59//! [`BuildContext::register_text_surface`]: crate::build_context::BuildContext::register_text_surface
60//! [`WidgetTree::focused_text_surface`]: crate::widget_tree::WidgetTree::focused_text_surface
61//! [`WidgetTree::text_surfaces`]: crate::widget_tree::WidgetTree::text_surfaces
62
63use std::cell::{Cell, RefCell};
64use std::rc::Rc;
65
66use crate::shortcut::StandardEdit;
67use crate::signal::Signal;
68use crate::text_input_document::{TextInputDocument, TextInputEditor};
69use crate::widget::EventContext;
70use crate::widget_id::WidgetId;
71
72/// A widget that edits text, seen through the commands a host may need to
73/// invoke on it from outside — a menu row, a routed shortcut, an assistive
74/// technology.
75///
76/// Object-safe on purpose: a host holds `Rc<dyn TextSurface>` for whichever
77/// widget has focus, without knowing which kind it is.
78pub trait TextSurface {
79    /// Is there anything in this surface's own history to step back through?
80    fn can_undo(&self) -> bool;
81    /// Is there anything to step forward into?
82    fn can_redo(&self) -> bool;
83    fn undo(&self);
84    fn redo(&self);
85
86    /// Is this surface refusing to step through its history at all?
87    ///
88    /// Distinct from having nothing to undo, and a host must treat them
89    /// differently: an empty history may be a reason to look elsewhere, a
90    /// refusal is not. Applications impose modes — a "forbid erasing" writing
91    /// game, a read-only review pass — and a routed Undo that quietly went
92    /// somewhere else would defeat them.
93    fn history_frozen(&self) -> bool {
94        false
95    }
96
97    /// Is any text selected right now?
98    fn has_selection(&self) -> bool;
99    /// Does this surface refuse edits? Cut and Paste are meaningless when it does.
100    fn is_read_only(&self) -> bool;
101    /// May its contents be copied at all? A password field says no.
102    fn allows_copy(&self) -> bool;
103
104    /// Does this surface take `command` at all, in the mode it is in now?
105    ///
106    /// The surface's own answer to a command from any route, the one its keys
107    /// give: a forward-only editor takes a paste and refuses a cut, a
108    /// read-only one refuses both, a masked password field refuses a copy.
109    /// [`is_read_only`](Self::is_read_only) cannot say "takes a paste, refuses
110    /// a cut", which is why this exists.
111    ///
112    /// Not whether there is anything to act on: a host greys a Cut row with
113    /// `!accepts(Cut) || !has_selection()`, an Undo row with
114    /// [`can_undo`](Self::can_undo) as well.
115    ///
116    /// An implementor's own commands must obey it: [`cut`](Self::cut),
117    /// [`copy`](Self::copy), [`paste`](Self::paste),
118    /// [`paste_plain`](Self::paste_plain), [`undo`](Self::undo),
119    /// [`redo`](Self::redo) and [`select_all`](Self::select_all) do nothing
120    /// for a command this refuses, so a shortcut a host routes to the focused
121    /// surface cannot write into a read-only one or take text from a
122    /// forward-only one. The trait cannot enforce that; the framework's text
123    /// widgets do it.
124    ///
125    /// The default answers from the other questions: a cut needs an editable
126    /// surface that allows copying, a paste an editable one, a copy one that
127    /// allows copying, Undo and Redo a history that is not frozen. It is only
128    /// as fine as they are, and cannot say "takes a paste, refuses a cut": an
129    /// implementor with such a mode overrides it.
130    fn accepts(&self, command: StandardEdit) -> bool {
131        match command {
132            StandardEdit::Cut => !self.is_read_only() && self.allows_copy(),
133            StandardEdit::Copy => self.allows_copy(),
134            StandardEdit::Paste | StandardEdit::PastePlain => !self.is_read_only(),
135            StandardEdit::Undo | StandardEdit::Redo => !self.history_frozen(),
136            StandardEdit::SelectAll => true,
137        }
138    }
139
140    fn cut(&self, ctx: &EventContext<'_>);
141    fn copy(&self, ctx: &EventContext<'_>);
142    fn paste(&self, ctx: &EventContext<'_>);
143    /// Paste stripped of formatting. A surface with no formatting to strip
144    /// should do a plain paste rather than nothing.
145    fn paste_plain(&self, ctx: &EventContext<'_>);
146    fn select_all(&self);
147
148    /// This surface's document, as the platform's text input system reads
149    /// it: the selection, the text, where a range is drawn. See
150    /// [`crate::text_input_document`].
151    ///
152    /// `None` by default, for a surface that has not described its document:
153    /// a bridge then tells the platform there is no document, and the
154    /// features that read one (dictation, an accent popup, Writing Tools) do
155    /// not start there. `RichTextEditor` and `TextInputField`, with the fields
156    /// built on it, answer.
157    fn input_document(&self) -> Option<&dyn TextInputDocument> {
158        None
159    }
160
161    /// The half of this surface's document the platform's input system
162    /// writes through. See
163    /// [`TextInputEditor`].
164    ///
165    /// `None` by default, and for a surface that takes no text now (a
166    /// read-only field).
167    fn input_editor(&self) -> Option<&dyn TextInputEditor> {
168        None
169    }
170}
171
172/// A cloneable view of one tree's registered text surfaces.
173///
174/// Taken once, with [`BuildContext::text_surfaces`], and held by whatever needs
175/// to ask the question later — a view-model refreshed from a frame tick has no
176/// `&WidgetTree` to consult, which is precisely when it needs the answer.
177///
178/// [`BuildContext::text_surfaces`]: crate::build_context::BuildContext::text_surfaces
179#[derive(Clone)]
180pub struct TextSurfaces {
181    entries: Rc<RefCell<Vec<(WidgetId, Rc<dyn TextSurface>)>>>,
182    focused: Signal<Option<WidgetId>>,
183    empty_history_passes_on: Rc<Cell<bool>>,
184}
185
186impl std::fmt::Debug for TextSurfaces {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("TextSurfaces")
189            .field("registered", &self.entries.borrow().len())
190            .field("focused_is_text", &self.focused_is_text_surface())
191            .field("empty_history_passes_on", &self.empty_history_passes_on())
192            .finish()
193    }
194}
195
196impl TextSurfaces {
197    pub(crate) fn new(focused: Signal<Option<WidgetId>>) -> Self {
198        Self {
199            entries: Rc::new(RefCell::new(Vec::new())),
200            focused,
201            empty_history_passes_on: Rc::new(Cell::new(false)),
202        }
203    }
204
205    /// Record that `owner` edits text, replacing any previous registration from
206    /// the same widget so a rebuild re-points rather than accumulating.
207    pub(crate) fn insert(&self, owner: WidgetId, surface: Rc<dyn TextSurface>) {
208        let mut entries = self.entries.borrow_mut();
209        entries.retain(|(id, _)| *id != owner);
210        entries.push((owner, surface));
211    }
212
213    /// Forget `owner`'s registration — on its rebuild or destroy.
214    pub(crate) fn remove(&self, owner: WidgetId) {
215        self.entries.borrow_mut().retain(|(id, _)| *id != owner);
216    }
217
218    /// The text-editing widget that currently holds the keyboard focus.
219    ///
220    /// `None` when focus is elsewhere — or nowhere — which is exactly what a
221    /// host needs in order to know that a text chord is safe to route itself.
222    pub fn focused(&self) -> Option<Rc<dyn TextSurface>> {
223        let focused = self.focused.get()?;
224        self.entries
225            .borrow()
226            .iter()
227            .find(|(id, _)| *id == focused)
228            .map(|(_, s)| Rc::clone(s))
229    }
230
231    /// The text widget registered under `owner`, focused or not.
232    ///
233    /// For a platform bridge that keeps serving one widget across a focus
234    /// change: a text service finishing its composition in the field the
235    /// writer just left (the press or the focus change that left it ends the
236    /// composition, and the text service writes its final text into that
237    /// field, not into the one that has the focus now).
238    pub fn surface(&self, owner: WidgetId) -> Option<Rc<dyn TextSurface>> {
239        self.entries
240            .borrow()
241            .iter()
242            .find(|(id, _)| *id == owner)
243            .map(|(_, s)| Rc::clone(s))
244    }
245
246    /// Is the keyboard focus inside a widget that edits text? The cheap half of
247    /// [`focused`](Self::focused), for a host that only needs to decide whether
248    /// to step aside.
249    pub fn focused_is_text_surface(&self) -> bool {
250        self.focused().is_some()
251    }
252
253    /// Run `f` on the document of the text widget that holds the keyboard
254    /// focus, as the platform's text input system reads it.
255    ///
256    /// `None` when focus is not on a text widget, or is on one that does not
257    /// describe its document ([`TextSurface::input_document`]). The question
258    /// a platform bridge asks each time the input system asks it where the
259    /// selection is.
260    pub fn with_focused_input_document<R>(
261        &self,
262        f: impl FnOnce(&dyn TextInputDocument) -> R,
263    ) -> Option<R> {
264        let surface = self.focused()?;
265        let document = surface.input_document()?;
266        Some(f(document))
267    }
268
269    /// Run `f` on the writing half of the focused text widget's document
270    /// ([`TextSurface::input_editor`]). `None` when focus is not on a text
271    /// widget, or on one that takes no writes from the input system.
272    pub fn with_focused_input_editor<R>(
273        &self,
274        f: impl FnOnce(&dyn TextInputEditor) -> R,
275    ) -> Option<R> {
276        let surface = self.focused()?;
277        let editor = surface.input_editor()?;
278        Some(f(editor))
279    }
280
281    /// Does the widget registered under `owner` refuse edits?
282    ///
283    /// `false` for a widget that edits nothing — the question is only asked of
284    /// text surfaces, and a node that is not one is not read-only, it is simply
285    /// not text. Read by the hit-test slop pass, which must never re-attribute
286    /// a press to a surface that would ignore it.
287    pub fn is_read_only(&self, owner: WidgetId) -> bool {
288        self.entries
289            .borrow()
290            .iter()
291            .find(|(id, _)| *id == owner)
292            .map(|(_, s)| s.is_read_only())
293            .unwrap_or(false)
294    }
295
296    /// Make every text widget of this tree decline Undo when it has nothing to
297    /// undo, and Redo when it has nothing to redo, so the intent goes on to an
298    /// ancestor's action — the application's own history — instead of stopping
299    /// at a widget that would do nothing with it. See the
300    /// [module documentation](self#an-empty-history-can-pass-the-command-on).
301    ///
302    /// Off by default. Applies to `RichTextEditor`, `TextInputField` and the
303    /// fields built on it, and `CodeEditor`, from the next command they
304    /// receive; set it once, from the root's `build`. The widgets ask their
305    /// document at the moment the command runs, never a cached
306    /// [`can_undo`](TextSurface::can_undo), so a keystroke typed a moment
307    /// earlier is always undone where it was typed.
308    pub fn set_empty_history_passes_on(&self, on: bool) {
309        self.empty_history_passes_on.set(on);
310    }
311
312    /// Whether a text widget with nothing to undo (or redo) passes the command
313    /// on. See [`set_empty_history_passes_on`](Self::set_empty_history_passes_on).
314    pub fn empty_history_passes_on(&self) -> bool {
315        self.empty_history_passes_on.get()
316    }
317
318    /// The signal to react to. A host mirroring "can undo" into a menu row
319    /// re-reads when focus moves.
320    pub fn focus_signal(&self) -> Signal<Option<WidgetId>> {
321        self.focused.clone()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    /// A surface with no rule of its own, answering only the older questions.
330    struct Plain {
331        read_only: bool,
332        allows_copy: bool,
333        frozen: bool,
334    }
335
336    impl TextSurface for Plain {
337        fn can_undo(&self) -> bool {
338            true
339        }
340        fn can_redo(&self) -> bool {
341            true
342        }
343        fn undo(&self) {}
344        fn redo(&self) {}
345        fn history_frozen(&self) -> bool {
346            self.frozen
347        }
348        fn has_selection(&self) -> bool {
349            true
350        }
351        fn is_read_only(&self) -> bool {
352            self.read_only
353        }
354        fn allows_copy(&self) -> bool {
355            self.allows_copy
356        }
357        fn cut(&self, _: &EventContext<'_>) {}
358        fn copy(&self, _: &EventContext<'_>) {}
359        fn paste(&self, _: &EventContext<'_>) {}
360        fn paste_plain(&self, _: &EventContext<'_>) {}
361        fn select_all(&self) {}
362    }
363
364    /// A surface describing a fixed document, as a platform bridge sees it.
365    struct Described {
366        text: &'static str,
367    }
368
369    impl crate::text_input_document::TextInputDocument for Described {
370        fn char_count(&self) -> Option<usize> {
371            Some(self.text.chars().count())
372        }
373        fn text_in(&self, range: std::ops::Range<usize>) -> Option<String> {
374            Some(
375                self.text
376                    .chars()
377                    .skip(range.start)
378                    .take(range.len())
379                    .collect(),
380            )
381        }
382        fn selection(&self) -> Option<std::ops::Range<usize>> {
383            Some(1..2)
384        }
385        fn caret(&self) -> Option<usize> {
386            Some(2)
387        }
388        fn composition_range(&self) -> Option<std::ops::Range<usize>> {
389            None
390        }
391        fn first_line_rect(
392            &self,
393            _: std::ops::Range<usize>,
394        ) -> Option<crate::text_input_document::FirstLineRect> {
395            None
396        }
397        fn offset_at_point(&self, _: teksilo_canvas::Point) -> Option<usize> {
398            None
399        }
400        fn visible_text_rect(&self) -> Option<teksilo_canvas::Rect> {
401            None
402        }
403        fn is_editable(&self) -> bool {
404            true
405        }
406        fn is_secure(&self) -> bool {
407            false
408        }
409        fn writing_tools_allowed(&self) -> bool {
410            true
411        }
412        fn utf16_index(&self) -> Option<Rc<crate::text_input_document::Utf16Index>> {
413            Some(Rc::new(crate::text_input_document::Utf16Index::new(
414                self.text,
415            )))
416        }
417    }
418
419    impl TextSurface for Described {
420        fn can_undo(&self) -> bool {
421            false
422        }
423        fn can_redo(&self) -> bool {
424            false
425        }
426        fn undo(&self) {}
427        fn redo(&self) {}
428        fn has_selection(&self) -> bool {
429            true
430        }
431        fn is_read_only(&self) -> bool {
432            false
433        }
434        fn allows_copy(&self) -> bool {
435            true
436        }
437        fn cut(&self, _: &EventContext<'_>) {}
438        fn copy(&self, _: &EventContext<'_>) {}
439        fn paste(&self, _: &EventContext<'_>) {}
440        fn paste_plain(&self, _: &EventContext<'_>) {}
441        fn select_all(&self) {}
442        fn input_document(&self) -> Option<&dyn crate::text_input_document::TextInputDocument> {
443            Some(self)
444        }
445    }
446
447    /// A bridge reaches the focused widget's document, and only a widget that
448    /// describes one: a surface that does not is no document, not an empty
449    /// one.
450    #[test]
451    fn the_focused_input_document_is_the_focused_widgets_own() {
452        let focused = Signal::new(None);
453        let surfaces = TextSurfaces::new(focused.clone());
454        let described = WidgetId::from(slotmap::KeyData::from_ffi(1 << 32 | 1));
455        let plain = WidgetId::from(slotmap::KeyData::from_ffi(1 << 32 | 2));
456        surfaces.insert(
457            described,
458            Rc::new(Described {
459                text: "a\u{1F600}b",
460            }),
461        );
462        surfaces.insert(
463            plain,
464            Rc::new(Plain {
465                read_only: false,
466                allows_copy: true,
467                frozen: false,
468            }),
469        );
470
471        assert_eq!(surfaces.with_focused_input_document(|d| d.caret()), None);
472
473        focused.set(Some(described));
474        assert_eq!(
475            surfaces.with_focused_input_document(|d| d.selection()),
476            Some(Some(1..2))
477        );
478        assert_eq!(
479            surfaces.with_focused_input_document(|d| d.char_range_to_utf16(1..2)),
480            Some(Some(1..3)),
481            "the emoji is two UTF-16 units"
482        );
483
484        focused.set(Some(plain));
485        assert_eq!(
486            surfaces.with_focused_input_document(|d| d.caret()),
487            None,
488            "a surface that describes no document answers nothing"
489        );
490
491        // A bridge still reaches the widget it serves once focus has left.
492        let left = surfaces.surface(described).expect("registered");
493        assert_eq!(
494            left.input_document().and_then(|d| d.selection()),
495            Some(1..2)
496        );
497        surfaces.remove(described);
498        assert!(
499            surfaces.surface(described).is_none(),
500            "gone with its widget"
501        );
502    }
503
504    /// The default `accepts` follows the questions an implementor already
505    /// answers, so one written before it existed keeps meaning what it said.
506    #[test]
507    fn the_default_accepts_follows_the_older_questions() {
508        let editable = Plain {
509            read_only: false,
510            allows_copy: true,
511            frozen: false,
512        };
513        for command in [
514            StandardEdit::Cut,
515            StandardEdit::Copy,
516            StandardEdit::Paste,
517            StandardEdit::PastePlain,
518            StandardEdit::Undo,
519            StandardEdit::Redo,
520            StandardEdit::SelectAll,
521        ] {
522            assert!(editable.accepts(command), "{command:?}");
523        }
524
525        let read_only = Plain {
526            read_only: true,
527            allows_copy: true,
528            frozen: true,
529        };
530        assert!(!read_only.accepts(StandardEdit::Cut));
531        assert!(!read_only.accepts(StandardEdit::Paste));
532        assert!(!read_only.accepts(StandardEdit::PastePlain));
533        assert!(!read_only.accepts(StandardEdit::Undo));
534        assert!(read_only.accepts(StandardEdit::Copy));
535        assert!(read_only.accepts(StandardEdit::SelectAll));
536
537        let secret = Plain {
538            read_only: false,
539            allows_copy: false,
540            frozen: false,
541        };
542        assert!(!secret.accepts(StandardEdit::Copy));
543        assert!(!secret.accepts(StandardEdit::Cut), "a cut is a copy too");
544        assert!(secret.accepts(StandardEdit::Paste));
545    }
546}