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