Skip to main content

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}