teksilo_widgets/editor_shortcuts.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The editors' own rebindable commands: bold and the rest of
5//! [`RichTextEditor`](crate::rich_text::RichTextEditor)'s formatting
6//! ([`FormatShortcut`]), comment and duplicate in
7//! [`CodeEditor`](crate::CodeEditor) ([`CodeShortcut`]).
8//!
9//! They work as the standard edit commands do
10//! ([`StandardEdit`](teksilo_core::shortcut::StandardEdit)): a global shortcut
11//! whose id is also the intent name, and an action on each editor that runs
12//! it, so the focused editor answers. Where the standard set is registered in
13//! every tree, these exist only while an editor that runs them does: each
14//! editable instance declares them with
15//! [`register_shortcut_shared`](teksilo_core::build_context::BuildContext::register_shortcut_shared),
16//! and the shortcut goes with the last. A read-only viewer declares none. A
17//! settings UI therefore lists Bold only in an application that has a
18//! rich-text editor, and a user's binding waits in the registry for the next
19//! one.
20//!
21//! An application that binds one of these chords to a global shortcut of its
22//! own shares it with the editor's while an editor exists.
23
24use teksilo_core::event::{Key, Modifiers};
25use teksilo_core::shortcut::{KeyStroke, Shortcut};
26use teksilo_i18n::{LocalizedString, tr_widget};
27
28/// The settings-UI category of every [`FormatShortcut`].
29pub const FORMAT_CATEGORY: &str = "format";
30
31/// The settings-UI category of every [`CodeShortcut`].
32pub const CODE_CATEGORY: &str = "code";
33
34/// A formatting command of `RichTextEditor`. Its [`id`](Self::id) is both the
35/// shortcut id and the intent name.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub enum FormatShortcut {
39 /// `format.bold`, Ctrl+B (⌘B on macOS).
40 Bold,
41 /// `format.italic`, Ctrl+I (⌘I on macOS).
42 Italic,
43 /// `format.underline`, Ctrl+U (⌘U on macOS).
44 Underline,
45 /// `format.insert_paragraph`, Ctrl+Enter (⌘Enter on macOS): a new
46 /// paragraph even inside a table cell, where Enter moves to the cell
47 /// below.
48 InsertParagraph,
49}
50
51impl FormatShortcut {
52 /// Every formatting command, in the order a settings UI lists them.
53 pub const ALL: [FormatShortcut; 4] = [
54 FormatShortcut::Bold,
55 FormatShortcut::Italic,
56 FormatShortcut::Underline,
57 FormatShortcut::InsertParagraph,
58 ];
59
60 /// The shortcut id, which is also the intent name.
61 pub const fn id(self) -> &'static str {
62 match self {
63 FormatShortcut::Bold => "format.bold",
64 FormatShortcut::Italic => "format.italic",
65 FormatShortcut::Underline => "format.underline",
66 FormatShortcut::InsertParagraph => "format.insert_paragraph",
67 }
68 }
69
70 /// The command whose [`id`](Self::id) is `id`.
71 pub fn from_id(id: &str) -> Option<Self> {
72 Self::ALL.into_iter().find(|command| command.id() == id)
73 }
74
75 /// The shortcut an editor declares for this command: its default chord, a
76 /// name in the active language, and the [`FORMAT_CATEGORY`] category.
77 pub fn shortcut(self) -> Shortcut {
78 let (name, key) = match self {
79 FormatShortcut::Bold => (tr_widget!(shortcut_format_bold()), Key::B),
80 FormatShortcut::Italic => (tr_widget!(shortcut_format_italic()), Key::I),
81 FormatShortcut::Underline => (tr_widget!(shortcut_format_underline()), Key::U),
82 FormatShortcut::InsertParagraph => {
83 (tr_widget!(shortcut_format_insert_paragraph()), Key::Enter)
84 }
85 };
86 described(
87 self.id(),
88 name,
89 FORMAT_CATEGORY,
90 tr_widget!(shortcut_category_format()),
91 )
92 .primary(KeyStroke::ctrl(key))
93 .build()
94 }
95}
96
97/// A command of `CodeEditor`. Its [`id`](Self::id) is both the shortcut id and
98/// the intent name.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100#[non_exhaustive]
101pub enum CodeShortcut {
102 /// `code.toggle_comment`, Ctrl+/ (⌘/ on macOS), and Ctrl+Shift+/ for the
103 /// layouts that type `/` with Shift (AZERTY, German).
104 ToggleComment,
105 /// `code.duplicate`, Ctrl+D (⌘D on macOS): the caret's line, or the
106 /// selection, below itself.
107 Duplicate,
108}
109
110impl CodeShortcut {
111 /// Every code command, in the order a settings UI lists them.
112 pub const ALL: [CodeShortcut; 2] = [CodeShortcut::ToggleComment, CodeShortcut::Duplicate];
113
114 /// The shortcut id, which is also the intent name.
115 pub const fn id(self) -> &'static str {
116 match self {
117 CodeShortcut::ToggleComment => "code.toggle_comment",
118 CodeShortcut::Duplicate => "code.duplicate",
119 }
120 }
121
122 /// The command whose [`id`](Self::id) is `id`.
123 pub fn from_id(id: &str) -> Option<Self> {
124 Self::ALL.into_iter().find(|command| command.id() == id)
125 }
126
127 /// The shortcut an editor declares for this command: its default chords, a
128 /// name in the active language, and the [`CODE_CATEGORY`] category.
129 pub fn shortcut(self) -> Shortcut {
130 let category = || tr_widget!(shortcut_category_code());
131 match self {
132 CodeShortcut::ToggleComment => described(
133 self.id(),
134 tr_widget!(shortcut_code_toggle_comment()),
135 CODE_CATEGORY,
136 category(),
137 )
138 .primary(KeyStroke::ctrl(Key::Character('/')))
139 // A layout that types `/` with Shift reports the chord with Shift
140 // held; on one that does not, Shift+/ types `?` and this never
141 // matches.
142 .secondary(KeyStroke::new(
143 Key::Character('/'),
144 Modifiers::CTRL | Modifiers::SHIFT,
145 ))
146 .build(),
147 CodeShortcut::Duplicate => described(
148 self.id(),
149 tr_widget!(shortcut_code_duplicate()),
150 CODE_CATEGORY,
151 category(),
152 )
153 .primary(KeyStroke::ctrl(Key::D))
154 .build(),
155 }
156 }
157}
158
159fn described(
160 id: &'static str,
161 name: LocalizedString,
162 category: &'static str,
163 category_label: LocalizedString,
164) -> teksilo_core::shortcut::ShortcutBuilder {
165 Shortcut::new(id)
166 .name(name)
167 .category(category)
168 .category_label(category_label)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn ids_round_trip() {
177 for command in FormatShortcut::ALL {
178 assert_eq!(FormatShortcut::from_id(command.id()), Some(command));
179 assert_eq!(command.shortcut().id, command.id());
180 }
181 for command in CodeShortcut::ALL {
182 assert_eq!(CodeShortcut::from_id(command.id()), Some(command));
183 assert_eq!(command.shortcut().id, command.id());
184 }
185 assert_eq!(FormatShortcut::from_id("code.duplicate"), None);
186 }
187}