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//! [`BuildContext::register_text_surface`]: crate::build_context::BuildContext::register_text_surface
51//! [`WidgetTree::focused_text_surface`]: crate::widget_tree::WidgetTree::focused_text_surface
52
53use std::cell::{Cell, RefCell};
54use std::rc::Rc;
55
56use crate::shortcut::StandardEdit;
57use crate::signal::Signal;
58use crate::widget::EventContext;
59use crate::widget_id::WidgetId;
60
61/// A widget that edits text, seen through the commands a host may need to
62/// invoke on it from outside — a menu row, a routed shortcut, an assistive
63/// technology.
64///
65/// Object-safe on purpose: a host holds `Rc<dyn TextSurface>` for whichever
66/// widget has focus, without knowing which kind it is.
67pub trait TextSurface {
68    /// Is there anything in this surface's own history to step back through?
69    fn can_undo(&self) -> bool;
70    /// Is there anything to step forward into?
71    fn can_redo(&self) -> bool;
72    fn undo(&self);
73    fn redo(&self);
74
75    /// Is this surface refusing to step through its history at all?
76    ///
77    /// Distinct from having nothing to undo, and a host must treat them
78    /// differently: an empty history may be a reason to look elsewhere, a
79    /// refusal is not. Applications impose modes — a "forbid erasing" writing
80    /// game, a read-only review pass — and a routed Undo that quietly went
81    /// somewhere else would defeat them.
82    fn history_frozen(&self) -> bool {
83        false
84    }
85
86    /// Is any text selected right now?
87    fn has_selection(&self) -> bool;
88    /// Does this surface refuse edits? Cut and Paste are meaningless when it does.
89    fn is_read_only(&self) -> bool;
90    /// May its contents be copied at all? A password field says no.
91    fn allows_copy(&self) -> bool;
92
93    /// Does this surface take `command` at all, in the mode it is in now?
94    ///
95    /// The surface's own answer to a command from any route, the one its keys
96    /// give: a forward-only editor takes a paste and refuses a cut, a
97    /// read-only one refuses both, a masked password field refuses a copy.
98    /// [`is_read_only`](Self::is_read_only) cannot say "takes a paste, refuses
99    /// a cut", which is why this exists.
100    ///
101    /// Not whether there is anything to act on: a host greys a Cut row with
102    /// `!accepts(Cut) || !has_selection()`, an Undo row with
103    /// [`can_undo`](Self::can_undo) as well.
104    ///
105    /// An implementor's own commands must obey it: [`cut`](Self::cut),
106    /// [`copy`](Self::copy), [`paste`](Self::paste),
107    /// [`paste_plain`](Self::paste_plain), [`undo`](Self::undo),
108    /// [`redo`](Self::redo) and [`select_all`](Self::select_all) do nothing
109    /// for a command this refuses, so a shortcut a host routes to the focused
110    /// surface cannot write into a read-only one or take text from a
111    /// forward-only one. The trait cannot enforce that; the framework's text
112    /// widgets do it.
113    ///
114    /// The default answers from the other questions: a cut needs an editable
115    /// surface that allows copying, a paste an editable one, a copy one that
116    /// allows copying, Undo and Redo a history that is not frozen. It is only
117    /// as fine as they are, and cannot say "takes a paste, refuses a cut": an
118    /// implementor with such a mode overrides it.
119    fn accepts(&self, command: StandardEdit) -> bool {
120        match command {
121            StandardEdit::Cut => !self.is_read_only() && self.allows_copy(),
122            StandardEdit::Copy => self.allows_copy(),
123            StandardEdit::Paste | StandardEdit::PastePlain => !self.is_read_only(),
124            StandardEdit::Undo | StandardEdit::Redo => !self.history_frozen(),
125            StandardEdit::SelectAll => true,
126        }
127    }
128
129    fn cut(&self, ctx: &EventContext<'_>);
130    fn copy(&self, ctx: &EventContext<'_>);
131    fn paste(&self, ctx: &EventContext<'_>);
132    /// Paste stripped of formatting. A surface with no formatting to strip
133    /// should do a plain paste rather than nothing.
134    fn paste_plain(&self, ctx: &EventContext<'_>);
135    fn select_all(&self);
136}
137
138/// A cloneable view of one tree's registered text surfaces.
139///
140/// Taken once, with [`BuildContext::text_surfaces`], and held by whatever needs
141/// to ask the question later — a view-model refreshed from a frame tick has no
142/// `&WidgetTree` to consult, which is precisely when it needs the answer.
143///
144/// [`BuildContext::text_surfaces`]: crate::build_context::BuildContext::text_surfaces
145#[derive(Clone)]
146pub struct TextSurfaces {
147    entries: Rc<RefCell<Vec<(WidgetId, Rc<dyn TextSurface>)>>>,
148    focused: Signal<Option<WidgetId>>,
149    empty_history_passes_on: Rc<Cell<bool>>,
150}
151
152impl std::fmt::Debug for TextSurfaces {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("TextSurfaces")
155            .field("registered", &self.entries.borrow().len())
156            .field("focused_is_text", &self.focused_is_text_surface())
157            .field("empty_history_passes_on", &self.empty_history_passes_on())
158            .finish()
159    }
160}
161
162impl TextSurfaces {
163    pub(crate) fn new(focused: Signal<Option<WidgetId>>) -> Self {
164        Self {
165            entries: Rc::new(RefCell::new(Vec::new())),
166            focused,
167            empty_history_passes_on: Rc::new(Cell::new(false)),
168        }
169    }
170
171    /// Record that `owner` edits text, replacing any previous registration from
172    /// the same widget so a rebuild re-points rather than accumulating.
173    pub(crate) fn insert(&self, owner: WidgetId, surface: Rc<dyn TextSurface>) {
174        let mut entries = self.entries.borrow_mut();
175        entries.retain(|(id, _)| *id != owner);
176        entries.push((owner, surface));
177    }
178
179    /// Forget `owner`'s registration — on its rebuild or destroy.
180    pub(crate) fn remove(&self, owner: WidgetId) {
181        self.entries.borrow_mut().retain(|(id, _)| *id != owner);
182    }
183
184    /// The text-editing widget that currently holds the keyboard focus.
185    ///
186    /// `None` when focus is elsewhere — or nowhere — which is exactly what a
187    /// host needs in order to know that a text chord is safe to route itself.
188    pub fn focused(&self) -> Option<Rc<dyn TextSurface>> {
189        let focused = self.focused.get()?;
190        self.entries
191            .borrow()
192            .iter()
193            .find(|(id, _)| *id == focused)
194            .map(|(_, s)| Rc::clone(s))
195    }
196
197    /// Is the keyboard focus inside a widget that edits text? The cheap half of
198    /// [`focused`](Self::focused), for a host that only needs to decide whether
199    /// to step aside.
200    pub fn focused_is_text_surface(&self) -> bool {
201        self.focused().is_some()
202    }
203
204    /// Does the widget registered under `owner` refuse edits?
205    ///
206    /// `false` for a widget that edits nothing — the question is only asked of
207    /// text surfaces, and a node that is not one is not read-only, it is simply
208    /// not text. Read by the hit-test slop pass, which must never re-attribute
209    /// a press to a surface that would ignore it.
210    pub fn is_read_only(&self, owner: WidgetId) -> bool {
211        self.entries
212            .borrow()
213            .iter()
214            .find(|(id, _)| *id == owner)
215            .map(|(_, s)| s.is_read_only())
216            .unwrap_or(false)
217    }
218
219    /// Make every text widget of this tree decline Undo when it has nothing to
220    /// undo, and Redo when it has nothing to redo, so the intent goes on to an
221    /// ancestor's action — the application's own history — instead of stopping
222    /// at a widget that would do nothing with it. See the
223    /// [module documentation](self#an-empty-history-can-pass-the-command-on).
224    ///
225    /// Off by default. Applies to `RichTextEditor`, `TextInputField` and the
226    /// fields built on it, and `CodeEditor`, from the next command they
227    /// receive; set it once, from the root's `build`. The widgets ask their
228    /// document at the moment the command runs, never a cached
229    /// [`can_undo`](TextSurface::can_undo), so a keystroke typed a moment
230    /// earlier is always undone where it was typed.
231    pub fn set_empty_history_passes_on(&self, on: bool) {
232        self.empty_history_passes_on.set(on);
233    }
234
235    /// Whether a text widget with nothing to undo (or redo) passes the command
236    /// on. See [`set_empty_history_passes_on`](Self::set_empty_history_passes_on).
237    pub fn empty_history_passes_on(&self) -> bool {
238        self.empty_history_passes_on.get()
239    }
240
241    /// The signal to react to. A host mirroring "can undo" into a menu row
242    /// re-reads when focus moves.
243    pub fn focus_signal(&self) -> Signal<Option<WidgetId>> {
244        self.focused.clone()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// A surface with no rule of its own, answering only the older questions.
253    struct Plain {
254        read_only: bool,
255        allows_copy: bool,
256        frozen: bool,
257    }
258
259    impl TextSurface for Plain {
260        fn can_undo(&self) -> bool {
261            true
262        }
263        fn can_redo(&self) -> bool {
264            true
265        }
266        fn undo(&self) {}
267        fn redo(&self) {}
268        fn history_frozen(&self) -> bool {
269            self.frozen
270        }
271        fn has_selection(&self) -> bool {
272            true
273        }
274        fn is_read_only(&self) -> bool {
275            self.read_only
276        }
277        fn allows_copy(&self) -> bool {
278            self.allows_copy
279        }
280        fn cut(&self, _: &EventContext<'_>) {}
281        fn copy(&self, _: &EventContext<'_>) {}
282        fn paste(&self, _: &EventContext<'_>) {}
283        fn paste_plain(&self, _: &EventContext<'_>) {}
284        fn select_all(&self) {}
285    }
286
287    /// The default `accepts` follows the questions an implementor already
288    /// answers, so one written before it existed keeps meaning what it said.
289    #[test]
290    fn the_default_accepts_follows_the_older_questions() {
291        let editable = Plain {
292            read_only: false,
293            allows_copy: true,
294            frozen: false,
295        };
296        for command in [
297            StandardEdit::Cut,
298            StandardEdit::Copy,
299            StandardEdit::Paste,
300            StandardEdit::PastePlain,
301            StandardEdit::Undo,
302            StandardEdit::Redo,
303            StandardEdit::SelectAll,
304        ] {
305            assert!(editable.accepts(command), "{command:?}");
306        }
307
308        let read_only = Plain {
309            read_only: true,
310            allows_copy: true,
311            frozen: true,
312        };
313        assert!(!read_only.accepts(StandardEdit::Cut));
314        assert!(!read_only.accepts(StandardEdit::Paste));
315        assert!(!read_only.accepts(StandardEdit::PastePlain));
316        assert!(!read_only.accepts(StandardEdit::Undo));
317        assert!(read_only.accepts(StandardEdit::Copy));
318        assert!(read_only.accepts(StandardEdit::SelectAll));
319
320        let secret = Plain {
321            read_only: false,
322            allows_copy: false,
323            frozen: false,
324        };
325        assert!(!secret.accepts(StandardEdit::Copy));
326        assert!(!secret.accepts(StandardEdit::Cut), "a cut is a copy too");
327        assert!(secret.accepts(StandardEdit::Paste));
328    }
329}