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}