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}