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::signal::Signal;
57use crate::widget::EventContext;
58use crate::widget_id::WidgetId;
59
60/// A widget that edits text, seen through the commands a host may need to
61/// invoke on it from outside — a menu row, a routed shortcut, an assistive
62/// technology.
63///
64/// Object-safe on purpose: a host holds `Rc<dyn TextSurface>` for whichever
65/// widget has focus, without knowing which kind it is.
66pub trait TextSurface {
67 /// Is there anything in this surface's own history to step back through?
68 fn can_undo(&self) -> bool;
69 /// Is there anything to step forward into?
70 fn can_redo(&self) -> bool;
71 fn undo(&self);
72 fn redo(&self);
73
74 /// Is this surface refusing to step through its history at all?
75 ///
76 /// Distinct from having nothing to undo, and a host must treat them
77 /// differently: an empty history may be a reason to look elsewhere, a
78 /// refusal is not. Applications impose modes — a "forbid erasing" writing
79 /// game, a read-only review pass — and a routed Undo that quietly went
80 /// somewhere else would defeat them.
81 fn history_frozen(&self) -> bool {
82 false
83 }
84
85 /// Is any text selected right now?
86 fn has_selection(&self) -> bool;
87 /// Does this surface refuse edits? Cut and Paste are meaningless when it does.
88 fn is_read_only(&self) -> bool;
89 /// May its contents be copied at all? A password field says no.
90 fn allows_copy(&self) -> bool;
91
92 fn cut(&self, ctx: &EventContext<'_>);
93 fn copy(&self, ctx: &EventContext<'_>);
94 fn paste(&self, ctx: &EventContext<'_>);
95 /// Paste stripped of formatting. A surface with no formatting to strip
96 /// should do a plain paste rather than nothing.
97 fn paste_plain(&self, ctx: &EventContext<'_>);
98 fn select_all(&self);
99}
100
101/// A cloneable view of one tree's registered text surfaces.
102///
103/// Taken once, with [`BuildContext::text_surfaces`], and held by whatever needs
104/// to ask the question later — a view-model refreshed from a frame tick has no
105/// `&WidgetTree` to consult, which is precisely when it needs the answer.
106///
107/// [`BuildContext::text_surfaces`]: crate::build_context::BuildContext::text_surfaces
108#[derive(Clone)]
109pub struct TextSurfaces {
110 entries: Rc<RefCell<Vec<(WidgetId, Rc<dyn TextSurface>)>>>,
111 focused: Signal<Option<WidgetId>>,
112 empty_history_passes_on: Rc<Cell<bool>>,
113}
114
115impl std::fmt::Debug for TextSurfaces {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.debug_struct("TextSurfaces")
118 .field("registered", &self.entries.borrow().len())
119 .field("focused_is_text", &self.focused_is_text_surface())
120 .field("empty_history_passes_on", &self.empty_history_passes_on())
121 .finish()
122 }
123}
124
125impl TextSurfaces {
126 pub(crate) fn new(focused: Signal<Option<WidgetId>>) -> Self {
127 Self {
128 entries: Rc::new(RefCell::new(Vec::new())),
129 focused,
130 empty_history_passes_on: Rc::new(Cell::new(false)),
131 }
132 }
133
134 /// Record that `owner` edits text, replacing any previous registration from
135 /// the same widget so a rebuild re-points rather than accumulating.
136 pub(crate) fn insert(&self, owner: WidgetId, surface: Rc<dyn TextSurface>) {
137 let mut entries = self.entries.borrow_mut();
138 entries.retain(|(id, _)| *id != owner);
139 entries.push((owner, surface));
140 }
141
142 /// Forget `owner`'s registration — on its rebuild or destroy.
143 pub(crate) fn remove(&self, owner: WidgetId) {
144 self.entries.borrow_mut().retain(|(id, _)| *id != owner);
145 }
146
147 /// The text-editing widget that currently holds the keyboard focus.
148 ///
149 /// `None` when focus is elsewhere — or nowhere — which is exactly what a
150 /// host needs in order to know that a text chord is safe to route itself.
151 pub fn focused(&self) -> Option<Rc<dyn TextSurface>> {
152 let focused = self.focused.get()?;
153 self.entries
154 .borrow()
155 .iter()
156 .find(|(id, _)| *id == focused)
157 .map(|(_, s)| Rc::clone(s))
158 }
159
160 /// Is the keyboard focus inside a widget that edits text? The cheap half of
161 /// [`focused`](Self::focused), for a host that only needs to decide whether
162 /// to step aside.
163 pub fn focused_is_text_surface(&self) -> bool {
164 self.focused().is_some()
165 }
166
167 /// Does the widget registered under `owner` refuse edits?
168 ///
169 /// `false` for a widget that edits nothing — the question is only asked of
170 /// text surfaces, and a node that is not one is not read-only, it is simply
171 /// not text. Read by the hit-test slop pass, which must never re-attribute
172 /// a press to a surface that would ignore it.
173 pub fn is_read_only(&self, owner: WidgetId) -> bool {
174 self.entries
175 .borrow()
176 .iter()
177 .find(|(id, _)| *id == owner)
178 .map(|(_, s)| s.is_read_only())
179 .unwrap_or(false)
180 }
181
182 /// Make every text widget of this tree decline Undo when it has nothing to
183 /// undo, and Redo when it has nothing to redo, so the intent goes on to an
184 /// ancestor's action — the application's own history — instead of stopping
185 /// at a widget that would do nothing with it. See the
186 /// [module documentation](self#an-empty-history-can-pass-the-command-on).
187 ///
188 /// Off by default. Applies to `RichTextEditor`, `TextInputField` and the
189 /// fields built on it, and `CodeEditor`, from the next command they
190 /// receive; set it once, from the root's `build`. The widgets ask their
191 /// document at the moment the command runs, never a cached
192 /// [`can_undo`](TextSurface::can_undo), so a keystroke typed a moment
193 /// earlier is always undone where it was typed.
194 pub fn set_empty_history_passes_on(&self, on: bool) {
195 self.empty_history_passes_on.set(on);
196 }
197
198 /// Whether a text widget with nothing to undo (or redo) passes the command
199 /// on. See [`set_empty_history_passes_on`](Self::set_empty_history_passes_on).
200 pub fn empty_history_passes_on(&self) -> bool {
201 self.empty_history_passes_on.get()
202 }
203
204 /// The signal to react to. A host mirroring "can undo" into a menu row
205 /// re-reads when focus moves.
206 pub fn focus_signal(&self) -> Signal<Option<WidgetId>> {
207 self.focused.clone()
208 }
209}