Skip to main content

teksilo_widgets/code_editor/
policy.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The code editor's command vocabulary and construction presets.
5//!
6//! The policy *machinery* — the command filter, the caret behaviour, the AT
7//! role, the clipboard surface, and the `PolicyBundle` that ties the four
8//! together — is shared with every other text surface and lives in the
9//! crate-internal `common::editor_runtime`. What lives here is only what is
10//! genuinely code-specific: the list of commands.
11//!
12//! That list is deliberately *not* the rich text editor's. `EditCommandKind`
13//! knows about tables, lists, blockquotes, and bold — none of which mean
14//! anything in a source file, and all of which would be reachable keystrokes if
15//! the vocabulary were reused. Conversely nothing here knows about any
16//! particular language: indentation width, comment tokens, and bracket pairs
17//! are injected configuration, so `ToggleLineComment` is a command and `//` is
18//! not.
19
20use crate::common::editor_runtime::{
21    AccessibilityRole, CaretPolicy, ClipboardPolicy, CommandFilter, EditorCommand, PolicyBundle,
22};
23
24/// Commands the code editor's keyboard layer may emit.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CodeCommand {
27    // --- Text editing ---
28    /// Insert typed text at every caret.
29    InsertChar,
30    /// Break the line, carrying the previous line's indentation when
31    /// auto-indent is on.
32    InsertNewline,
33    DeletePrev,
34    DeleteNext,
35    DeleteWordLeft,
36    DeleteWordRight,
37
38    // --- Code structure (all driven by injected configuration) ---
39    /// Tab with a selection, or with `use_soft_tabs`: indent by one level.
40    IndentLines,
41    /// Shift+Tab: remove one indent level from every touched line.
42    DedentLines,
43    /// Comment or uncomment the touched lines with the configured token.
44    /// A no-op when no line-comment token is configured — the editor knows
45    /// the *operation*, the app supplies the language.
46    ToggleLineComment,
47    /// Duplicate the caret's line (or the selection) below itself.
48    DuplicateSelection,
49    MoveLineUp,
50    MoveLineDown,
51
52    // --- Multi-caret ---
53    /// Add a caret on the line above / below the topmost / bottommost one.
54    AddCaretAbove,
55    AddCaretBelow,
56    /// Collapse back to a single caret (Escape).
57    ClearExtraCarets,
58
59    // --- History ---
60    Undo,
61    Redo,
62
63    // --- Navigation (never mutates, always accepted) ---
64    MoveLeft,
65    MoveRight,
66    MoveUp,
67    MoveDown,
68    MoveWordLeft,
69    MoveWordRight,
70    /// Home. Toggles between the first non-whitespace character and column 0 —
71    /// the near-universal code-editor behaviour.
72    MoveLineStart,
73    MoveLineEnd,
74    MoveDocStart,
75    MoveDocEnd,
76    PageUp,
77    PageDown,
78    SelectLeft,
79    SelectRight,
80    SelectUp,
81    SelectDown,
82    SelectWordLeft,
83    SelectWordRight,
84    SelectLineStart,
85    SelectLineEnd,
86    SelectDocStart,
87    SelectDocEnd,
88    SelectAll,
89
90    // --- Clipboard ---
91    Copy,
92    Cut,
93    Paste,
94}
95
96impl EditorCommand for CodeCommand {
97    /// True for commands that can take text away.
98    ///
99    /// `CommandFilter::ForwardOnly` is a prose-drafting mode and no code
100    /// surface uses it today, but the classification is exhaustive anyway so
101    /// the two vocabularies cannot drift: a command added here has to answer
102    /// the question rather than inherit a permissive default. The line-motion
103    /// commands count as regressive because each removes a line from where it
104    /// was, and `DuplicateSelection` does not because it only adds.
105    fn is_regressive(&self) -> bool {
106        matches!(
107            self,
108            Self::DeletePrev
109                | Self::DeleteNext
110                | Self::DeleteWordLeft
111                | Self::DeleteWordRight
112                | Self::Cut
113                | Self::Undo
114                | Self::Redo
115                | Self::MoveLineUp
116                | Self::MoveLineDown
117        )
118    }
119
120    fn mutates_document(&self) -> bool {
121        matches!(
122            self,
123            Self::InsertChar
124                | Self::InsertNewline
125                | Self::DeletePrev
126                | Self::DeleteNext
127                | Self::DeleteWordLeft
128                | Self::DeleteWordRight
129                | Self::IndentLines
130                | Self::DedentLines
131                | Self::ToggleLineComment
132                | Self::DuplicateSelection
133                | Self::MoveLineUp
134                | Self::MoveLineDown
135                | Self::Undo
136                | Self::Redo
137                | Self::Cut
138                | Self::Paste
139        )
140    }
141}
142
143/// The editable preset, shared by `CodeEditor::new` and
144/// `PlainTextEditor::new`: every command accepted, blinking caret,
145/// `MultilineTextInput` role, full clipboard.
146pub const CODE_EDITOR_PRESET: PolicyBundle = PolicyBundle {
147    command_filter: CommandFilter::All,
148    caret_policy: CaretPolicy::Blinking,
149    access_role: AccessibilityRole::Editor,
150    clipboard_policy: ClipboardPolicy::Full,
151};
152
153/// The read-only preset for `CodeEditor::read_only` / `PlainTextEditor::read_only`
154/// and for a streaming log view.
155///
156/// Navigation and copy only, `Role::Document`, no caret.
157///
158/// `Document` rather than `Role::Code` or `Role::Log` is a correctness
159/// constraint, not taste: `accesskit_consumer::Node::supports_text_ranges()`
160/// admits only text inputs plus `Label | Document | Terminal`. A viewer
161/// reporting `Code` or `Log` would render its text to a screen reader once and
162/// then never report the caret or selection moving through it — the reader
163/// could not navigate what it had just announced. A log that wants its new
164/// lines spoken pairs this with an explicit `Live`, which is an independent
165/// property rather than something a role implies.
166pub const CODE_READ_ONLY_PRESET: PolicyBundle = PolicyBundle {
167    command_filter: CommandFilter::ReadOnly,
168    caret_policy: CaretPolicy::Hidden,
169    access_role: AccessibilityRole::Document,
170    clipboard_policy: ClipboardPolicy::CopyAndSelectAllOnly,
171};
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn read_only_rejects_every_code_mutation() {
179        let f = CommandFilter::ReadOnly;
180        for cmd in [
181            CodeCommand::InsertChar,
182            CodeCommand::InsertNewline,
183            CodeCommand::DeletePrev,
184            CodeCommand::IndentLines,
185            CodeCommand::DedentLines,
186            CodeCommand::ToggleLineComment,
187            CodeCommand::DuplicateSelection,
188            CodeCommand::MoveLineUp,
189            CodeCommand::MoveLineDown,
190            CodeCommand::Undo,
191            CodeCommand::Redo,
192            CodeCommand::Cut,
193            CodeCommand::Paste,
194        ] {
195            assert!(!f.accepts(cmd), "read-only must reject {cmd:?}");
196        }
197    }
198
199    #[test]
200    fn read_only_still_navigates_selects_and_copies() {
201        let f = CommandFilter::ReadOnly;
202        for cmd in [
203            CodeCommand::MoveLeft,
204            CodeCommand::MoveLineStart,
205            CodeCommand::PageDown,
206            CodeCommand::SelectWordRight,
207            CodeCommand::SelectAll,
208            CodeCommand::Copy,
209        ] {
210            assert!(f.accepts(cmd), "read-only must accept {cmd:?}");
211        }
212    }
213
214    /// Adding a caret does not touch the document, so a viewer may hold
215    /// several — which is what makes a multi-caret *selection* copyable out of
216    /// a read-only log.
217    #[test]
218    fn caret_management_is_not_a_mutation() {
219        let f = CommandFilter::ReadOnly;
220        assert!(f.accepts(CodeCommand::AddCaretAbove));
221        assert!(f.accepts(CodeCommand::AddCaretBelow));
222        assert!(f.accepts(CodeCommand::ClearExtraCarets));
223    }
224
225    #[test]
226    fn editor_preset_accepts_everything() {
227        let f = CommandFilter::All;
228        for cmd in [
229            CodeCommand::InsertChar,
230            CodeCommand::ToggleLineComment,
231            CodeCommand::Paste,
232            CodeCommand::MoveLeft,
233        ] {
234            assert!(f.accepts(cmd));
235        }
236    }
237
238    #[test]
239    fn presets_report_read_only_correctly() {
240        assert!(!CODE_EDITOR_PRESET.is_read_only());
241        assert!(CODE_READ_ONLY_PRESET.is_read_only());
242    }
243
244    /// The AT role must stay inside the set accesskit_consumer will report
245    /// text ranges for; `Role::Code` / `Role::Log` are outside it and would
246    /// silently kill caret + selection reporting.
247    #[test]
248    fn read_only_preset_uses_a_text_range_capable_role() {
249        assert_eq!(
250            CODE_READ_ONLY_PRESET.access_role,
251            AccessibilityRole::Document
252        );
253    }
254}