Skip to main content

teksilo_widgets/rich_text/
policy.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Policy bundles for RichTextEditor construction presets.
5//!
6//! A `PolicyBundle` captures every decision that varies between the
7//! `editor()` and `read_only()` constructors so each widget never
8//! consults a `read_only: bool` flag. The four independent dimensions
9//! mirror §27.10.1: command filter, caret behaviour, accessibility role,
10//! and clipboard capabilities.
11
12/// Commands the keyboard handler may emit. Editing commands map to
13/// `TextCursor` mutations; navigation commands are always accepted.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum EditCommandKind {
16    // Editing
17    InsertChar,
18    InsertBlock,
19    /// Explicit block-insert, bypassing any Enter-in-table-cell
20    /// navigation (Ctrl+Enter). Separate from `InsertBlock` so an
21    /// app that wants only one of the two behaviours can gate them
22    /// independently through the `CommandFilter`.
23    InsertBlockForced,
24    /// Literal tab insertion (Tab key outside tables and lists).
25    InsertTab,
26    /// Navigate to an adjacent table cell (Tab / Shift+Tab when the
27    /// caret is inside a table).
28    NavigateTableCell,
29    /// Enter inside a table cell: navigate to the cell below (or
30    /// step out of the table on the last row).
31    NavigateTableCellDown,
32    /// Exit a list item (Backspace at block-start, indent 0).
33    ExitList,
34    /// Pop the cursor out of the innermost enclosing blockquote frame
35    /// (Backspace at the first position of the first quoted block;
36    /// Enter on an empty quoted paragraph; Delete at the last position
37    /// of the last quoted block).
38    ExitFrame,
39    /// Wrap the current block (or selection) in a blockquote, or
40    /// unwrap if already inside one. Toolbar / Ctrl+Shift+Q.
41    ToggleBlockquote,
42    /// Tab inside a blockquote (no list active) to nest deeper.
43    IncreaseBlockquoteDepth,
44    /// Shift+Tab inside a blockquote (no list active) to nest shallower.
45    DecreaseBlockquoteDepth,
46    DeletePrev,
47    DeleteNext,
48    DeleteWordLeft,
49    DeleteWordRight,
50    /// Move the caret's list item one level deeper (Tab in a list; the
51    /// `indent` command).
52    IndentBlock,
53    DedentBlock,
54    ToggleBold,
55    ToggleItalic,
56    ToggleUnderline,
57    Undo,
58    Redo,
59
60    // Navigation (always allowed regardless of policy)
61    MoveLeft,
62    MoveRight,
63    MoveUp,
64    MoveDown,
65    MoveWordLeft,
66    MoveWordRight,
67    MoveHome,
68    MoveEnd,
69    MoveDocStart,
70    MoveDocEnd,
71    PageUp,
72    PageDown,
73    SelectLeft,
74    SelectRight,
75    SelectUp,
76    SelectDown,
77    SelectWordLeft,
78    SelectWordRight,
79    SelectHome,
80    SelectEnd,
81    SelectDocStart,
82    SelectDocEnd,
83    SelectAll,
84
85    // Clipboard (allowed subset depends on policy)
86    Copy,
87    Cut,
88    Paste,
89    /// Paste as plain text — strips any rich fragment or HTML payload
90    /// and inserts only the plain-text portion. Bound to Ctrl+Shift+V
91    /// (⌘⇧V on macOS). Distinct from [`Paste`](Self::Paste) so the
92    /// command filter and `ClipboardPolicy` can gate it independently
93    /// (e.g. a future "no-paste" preset could still allow explicit
94    /// plain-text pasting).
95    PasteUnformatted,
96}
97
98impl crate::common::editor_runtime::EditorCommand for EditCommandKind {
99    /// True for commands that can take text away — a delete, a cut, or a
100    /// history step that reverts one.
101    ///
102    /// Deliberately much narrower than
103    /// [`mutates_document`](crate::common::editor_runtime::EditorCommand::mutates_document):
104    ///
105    /// * **Inserts are never regressive.** `InsertChar`/`InsertBlock`/
106    ///   `InsertTab`/`Paste` only add — the type-over case (inserting *over* a
107    ///   selection) is handled by collapsing the selection at the insert site,
108    ///   not by rejecting the keystroke.
109    /// * **Structure commands are not regressive.** `ExitList`, `ExitFrame`,
110    ///   the blockquote depth pair and `IndentBlock`/`DedentBlock` re-shape a
111    ///   block without dropping any of its characters. Rejecting them would
112    ///   leave a writer stuck inside a list or a quote with no way back out —
113    ///   punishing them for a structure they created, which is not what
114    ///   "don't delete your prose" means.
115    /// * **Formatting is not regressive.** Turning bold off changes how existing
116    ///   text looks, never whether it is there.
117    /// * **`Redo` is regressive**, not just `Undo`: it can re-apply a deletion
118    ///   made before the mode was switched on.
119    fn is_regressive(&self) -> bool {
120        matches!(
121            self,
122            Self::DeletePrev
123                | Self::DeleteNext
124                | Self::DeleteWordLeft
125                | Self::DeleteWordRight
126                | Self::Cut
127                | Self::Undo
128                | Self::Redo
129        )
130    }
131
132    /// True for commands that modify the document. Navigation and copy
133    /// never mutate.
134    fn mutates_document(&self) -> bool {
135        matches!(
136            self,
137            Self::InsertChar
138                | Self::InsertBlock
139                | Self::InsertBlockForced
140                | Self::InsertTab
141                | Self::ExitList
142                | Self::ExitFrame
143                | Self::ToggleBlockquote
144                | Self::IncreaseBlockquoteDepth
145                | Self::DecreaseBlockquoteDepth
146                | Self::NavigateTableCell
147                | Self::NavigateTableCellDown
148                | Self::DeletePrev
149                | Self::DeleteNext
150                | Self::DeleteWordLeft
151                | Self::DeleteWordRight
152                | Self::IndentBlock
153                | Self::DedentBlock
154                | Self::ToggleBold
155                | Self::ToggleItalic
156                | Self::ToggleUnderline
157                | Self::Undo
158                | Self::Redo
159                | Self::Cut
160                | Self::Paste
161                | Self::PasteUnformatted
162        )
163    }
164}
165
166// The policy machinery — command filter, AT role, clipboard surface, and the
167// bundle that ties them together — is shared with every other text surface and
168// lives in `common::editor_runtime`. Only the *vocabulary* above is
169// rich-text-specific. Re-exported here because these are their public names.
170pub use crate::common::editor_runtime::{
171    AccessibilityRole, CaretPolicy, ClipboardPolicy, CommandFilter, EDITOR_PRESET, PolicyBundle,
172    READ_ONLY_PRESET,
173};
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn read_only_filter_blocks_mutations() {
181        let f = CommandFilter::ReadOnly;
182        assert!(!f.accepts(EditCommandKind::InsertChar));
183        assert!(!f.accepts(EditCommandKind::DeletePrev));
184        assert!(!f.accepts(EditCommandKind::ToggleBold));
185        assert!(!f.accepts(EditCommandKind::Cut));
186        assert!(!f.accepts(EditCommandKind::Paste));
187        assert!(
188            !f.accepts(EditCommandKind::PasteUnformatted),
189            "read-only must reject PasteUnformatted — it mutates the document"
190        );
191        assert!(!f.accepts(EditCommandKind::Undo));
192    }
193
194    /// The whole point of the forward-only filter: the draft may grow but
195    /// nothing already written can be taken away from the keyboard.
196    #[test]
197    fn forward_only_filter_blocks_every_regressive_command() {
198        let f = CommandFilter::ForwardOnly;
199        for cmd in [
200            EditCommandKind::DeletePrev,
201            EditCommandKind::DeleteNext,
202            EditCommandKind::DeleteWordLeft,
203            EditCommandKind::DeleteWordRight,
204            EditCommandKind::Cut,
205            EditCommandKind::Undo,
206        ] {
207            assert!(
208                !f.accepts(cmd),
209                "{cmd:?} takes text away and must be rejected"
210            );
211        }
212        assert!(
213            !f.accepts(EditCommandKind::Redo),
214            "redo can re-apply a deletion made before the mode was switched on"
215        );
216    }
217
218    /// A forward-only surface is still an editor: everything additive, every
219    /// navigation, and — critically — the structure commands that let a writer
220    /// leave a list or a blockquote must all keep working.
221    #[test]
222    fn forward_only_filter_allows_additive_navigation_and_structure() {
223        let f = CommandFilter::ForwardOnly;
224        for cmd in [
225            EditCommandKind::InsertChar,
226            EditCommandKind::InsertBlock,
227            EditCommandKind::InsertBlockForced,
228            EditCommandKind::InsertTab,
229            EditCommandKind::Paste,
230            EditCommandKind::PasteUnformatted,
231            EditCommandKind::Copy,
232            EditCommandKind::SelectAll,
233            EditCommandKind::MoveLeft,
234            EditCommandKind::MoveDocEnd,
235            EditCommandKind::SelectWordRight,
236            EditCommandKind::ToggleBold,
237            EditCommandKind::ToggleItalic,
238            EditCommandKind::ToggleUnderline,
239            EditCommandKind::IndentBlock,
240            EditCommandKind::DedentBlock,
241            EditCommandKind::ToggleBlockquote,
242            EditCommandKind::IncreaseBlockquoteDepth,
243            EditCommandKind::DecreaseBlockquoteDepth,
244            EditCommandKind::NavigateTableCell,
245            EditCommandKind::NavigateTableCellDown,
246        ] {
247            assert!(
248                f.accepts(cmd),
249                "{cmd:?} adds or navigates and must be allowed"
250            );
251        }
252        assert!(
253            f.accepts(EditCommandKind::ExitList) && f.accepts(EditCommandKind::ExitFrame),
254            "popping out of a list or a quote drops no characters — blocking it \
255             would trap the writer inside the structure with no way out"
256        );
257    }
258
259    /// `is_regressive` must be strictly narrower than `mutates_document`:
260    /// anything that takes text away necessarily changes the document, and the
261    /// forward-only filter must accept strictly more than the read-only one.
262    #[test]
263    fn regressive_is_a_strict_subset_of_mutating() {
264        let all = [
265            EditCommandKind::InsertChar,
266            EditCommandKind::InsertBlock,
267            EditCommandKind::InsertBlockForced,
268            EditCommandKind::InsertTab,
269            EditCommandKind::NavigateTableCell,
270            EditCommandKind::NavigateTableCellDown,
271            EditCommandKind::ExitList,
272            EditCommandKind::ExitFrame,
273            EditCommandKind::ToggleBlockquote,
274            EditCommandKind::IncreaseBlockquoteDepth,
275            EditCommandKind::DecreaseBlockquoteDepth,
276            EditCommandKind::DeletePrev,
277            EditCommandKind::DeleteNext,
278            EditCommandKind::DeleteWordLeft,
279            EditCommandKind::DeleteWordRight,
280            EditCommandKind::IndentBlock,
281            EditCommandKind::DedentBlock,
282            EditCommandKind::ToggleBold,
283            EditCommandKind::ToggleItalic,
284            EditCommandKind::ToggleUnderline,
285            EditCommandKind::Undo,
286            EditCommandKind::Redo,
287            EditCommandKind::MoveLeft,
288            EditCommandKind::Copy,
289            EditCommandKind::Cut,
290            EditCommandKind::Paste,
291            EditCommandKind::PasteUnformatted,
292        ];
293        use crate::common::editor_runtime::EditorCommand;
294        let mut strictly_narrower = false;
295        for cmd in all {
296            if cmd.is_regressive() {
297                assert!(
298                    cmd.mutates_document(),
299                    "{cmd:?} claims to take text away without mutating the document"
300                );
301            }
302            if cmd.mutates_document() && !cmd.is_regressive() {
303                strictly_narrower = true;
304            }
305            // Whatever read-only permits, forward-only permits too.
306            if CommandFilter::ReadOnly.accepts(cmd) {
307                assert!(
308                    CommandFilter::ForwardOnly.accepts(cmd),
309                    "{cmd:?} is allowed read-only but rejected forward-only"
310                );
311            }
312        }
313        assert!(strictly_narrower, "the two predicates collapsed into one");
314    }
315
316    /// Type-over is a delete that happens below the command layer, so the
317    /// filter has to tell insert sites when to collapse the selection first.
318    #[test]
319    fn only_forward_only_collapses_the_selection_before_inserting() {
320        assert!(CommandFilter::ForwardOnly.collapses_selection_before_insert());
321        assert!(!CommandFilter::All.collapses_selection_before_insert());
322        assert!(!CommandFilter::ReadOnly.collapses_selection_before_insert());
323    }
324
325    /// An assistive-technology `SetValue` replaces the whole document; only an
326    /// unrestricted surface may do that.
327    #[test]
328    fn only_the_unrestricted_filter_allows_wholesale_replacement() {
329        assert!(CommandFilter::All.allows_wholesale_replacement());
330        assert!(!CommandFilter::ForwardOnly.allows_wholesale_replacement());
331        assert!(!CommandFilter::ReadOnly.allows_wholesale_replacement());
332    }
333
334    /// A forward-only preset must stay an *editor* in every other dimension:
335    /// hiding the caret or reporting `Document` to a screen reader would turn a
336    /// drafting mode into a viewer.
337    #[test]
338    fn with_command_filter_changes_only_the_filter() {
339        let fwd = EDITOR_PRESET.with_command_filter(CommandFilter::ForwardOnly);
340        assert_eq!(fwd.command_filter, CommandFilter::ForwardOnly);
341        assert!(!fwd.is_read_only());
342        assert_eq!(fwd.caret_policy, EDITOR_PRESET.caret_policy);
343        assert_eq!(fwd.access_role, EDITOR_PRESET.access_role);
344        assert_eq!(fwd.clipboard_policy, EDITOR_PRESET.clipboard_policy);
345    }
346
347    #[test]
348    fn paste_unformatted_policy_mirrors_paste() {
349        let full = ClipboardPolicy::Full;
350        let ro = ClipboardPolicy::CopyAndSelectAllOnly;
351        assert!(full.allows_paste());
352        assert!(full.allows_paste_unformatted());
353        assert!(!ro.allows_paste());
354        assert!(!ro.allows_paste_unformatted());
355    }
356
357    #[test]
358    fn read_only_filter_allows_navigation_and_copy() {
359        let f = CommandFilter::ReadOnly;
360        assert!(f.accepts(EditCommandKind::MoveLeft));
361        assert!(f.accepts(EditCommandKind::SelectWordRight));
362        assert!(f.accepts(EditCommandKind::MoveDocEnd));
363        assert!(f.accepts(EditCommandKind::Copy));
364        assert!(f.accepts(EditCommandKind::SelectAll));
365    }
366
367    #[test]
368    fn editor_filter_accepts_everything() {
369        let f = CommandFilter::All;
370        for cmd in [
371            EditCommandKind::InsertChar,
372            EditCommandKind::Paste,
373            EditCommandKind::Undo,
374            EditCommandKind::Copy,
375            EditCommandKind::MoveHome,
376        ] {
377            assert!(f.accepts(cmd));
378        }
379    }
380
381    #[test]
382    fn presets_report_read_only_correctly() {
383        assert!(!EDITOR_PRESET.is_read_only());
384        assert!(READ_ONLY_PRESET.is_read_only());
385    }
386}