Skip to main content

teksilo_widgets/common/
standard_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The standard edit commands, as the widgets of this crate take them.
5//!
6//! Every widget tree binds cut, copy, paste, undo, redo and select-all to
7//! rebindable shortcuts ([`StandardEdit`]). A widget that implements any of
8//! them registers an action per command with
9//! [`register_standard_edit_actions`], on the node that holds the keyboard
10//! focus, and no longer matches the chords itself: a key handler that still
11//! matched Ctrl+C would keep copying after the user moved Copy elsewhere.
12
13use std::rc::Rc;
14
15use teksilo_core::action::Action;
16use teksilo_core::build_context::BuildContext;
17use teksilo_core::intent::IntentResponse;
18use teksilo_core::shortcut::{ShortcutRegistry, StandardEdit};
19use teksilo_core::widget::EventContext;
20use teksilo_i18n::{LocalizedString, tr_widget};
21
22/// Register, on the widget being built, an action for every standard edit
23/// command. `run` performs one and says whether it did.
24///
25/// A command `run` declines is passed on: to an ancestor's action (an
26/// application's own Paste, say), and failing that to the focused widget as
27/// the plain key it was.
28pub(crate) fn register_standard_edit_actions(
29    ctx: &mut BuildContext,
30    run: impl Fn(StandardEdit, &mut EventContext) -> bool + 'static,
31) {
32    let run = Rc::new(run);
33    for command in StandardEdit::ALL {
34        let run = Rc::clone(&run);
35        register_standard_edit_action(ctx, command, move |ctx| run(command, ctx));
36    }
37}
38
39/// [`register_standard_edit_actions`] for a widget that implements one
40/// command only — a data view's select-all.
41pub(crate) fn register_standard_edit_action(
42    ctx: &mut BuildContext,
43    command: StandardEdit,
44    run: impl FnMut(&mut EventContext) -> bool + 'static,
45) {
46    register_command_action(ctx, command.id(), run);
47}
48
49/// Register, on the widget being built, the action for the command `id`
50/// names: the standard edit commands, and the editors' own
51/// [`FormatShortcut`](crate::editor_shortcuts::FormatShortcut) and
52/// [`CodeShortcut`](crate::editor_shortcuts::CodeShortcut). A command `run`
53/// declines is passed on, as for [`register_standard_edit_actions`].
54pub(crate) fn register_command_action(
55    ctx: &mut BuildContext,
56    id: &'static str,
57    mut run: impl FnMut(&mut EventContext) -> bool + 'static,
58) {
59    ctx.register_action(
60        Action::new(id).on_invoke_with_response(move |_intent, ctx| {
61            if run(ctx) {
62                IntentResponse::Handled
63            } else {
64                IntentResponse::Propagated
65            }
66        }),
67    );
68}
69
70/// Give the standard edit shortcuts in `registry` their names in the active
71/// language, which follow it from then on.
72///
73/// `teksilo-core` registers them with English names because it carries no
74/// translations; `teksilo-app` calls this for every window it opens. A no-op
75/// when no i18n manager is installed on this thread, which leaves the English
76/// names. User rebindings are kept.
77pub fn localize_standard_shortcuts(registry: &mut ShortcutRegistry) {
78    if teksilo_i18n::thread_local::with_active(|_| ()).is_none() {
79        return;
80    }
81    for command in StandardEdit::ALL {
82        let Some(name) = localized_name(command) else {
83            continue;
84        };
85        let mut shortcut = command.shortcut();
86        shortcut.name = name.into();
87        shortcut.category_label = Some(tr_widget!(shortcut_category_edit()).into());
88        registry.register(shortcut);
89    }
90}
91
92/// The name a command is shown under: the same words as the context-menu
93/// rows that run it.
94fn localized_name(command: StandardEdit) -> Option<LocalizedString> {
95    Some(match command {
96        StandardEdit::Cut => tr_widget!(menu_cut()),
97        StandardEdit::Copy => tr_widget!(menu_copy()),
98        StandardEdit::Paste => tr_widget!(menu_paste()),
99        StandardEdit::PastePlain => tr_widget!(menu_paste_unformatted()),
100        StandardEdit::Undo => tr_widget!(menu_undo()),
101        StandardEdit::Redo => tr_widget!(menu_redo()),
102        StandardEdit::SelectAll => tr_widget!(menu_select_all()),
103        _ => return None,
104    })
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::common::locale_switch_test::speaking;
111
112    fn shown(registry: &ShortcutRegistry, command: StandardEdit) -> (String, Option<String>) {
113        let effective = registry.effective(command.id()).expect("registered");
114        (
115            effective.shortcut.name.get(),
116            effective.shortcut.category_text(),
117        )
118    }
119
120    #[test]
121    fn the_names_follow_the_language_and_the_bindings_survive() {
122        let (mgr, mut tree) = speaking("fr-FR");
123        let undo_too = teksilo_core::shortcut::KeyStroke::new(
124            teksilo_core::event::Key::Character('à'),
125            teksilo_core::event::Modifiers::CTRL,
126        );
127        tree.shortcut_registry_mut()
128            .rebind_secondary(StandardEdit::Undo.id(), Some(undo_too));
129
130        localize_standard_shortcuts(tree.shortcut_registry_mut());
131        let registry = tree.shortcut_registry();
132        assert_eq!(
133            shown(registry, StandardEdit::Undo),
134            ("Annuler".to_string(), Some("Édition".to_string()))
135        );
136        assert_eq!(
137            shown(registry, StandardEdit::SelectAll).0,
138            "Tout sélectionner"
139        );
140        assert_eq!(
141            registry
142                .effective(StandardEdit::Undo.id())
143                .unwrap()
144                .secondary,
145            Some(undo_too),
146            "localizing must not drop a user's binding"
147        );
148
149        mgr.set_locale("en-US".parse().unwrap());
150        assert_eq!(
151            shown(tree.shortcut_registry(), StandardEdit::Undo),
152            ("Undo".to_string(), Some("Edit".to_string())),
153            "the names follow a change of language"
154        );
155        teksilo_i18n::thread_local::clear();
156    }
157
158    #[test]
159    fn without_a_manager_the_english_names_stay() {
160        teksilo_i18n::thread_local::clear();
161        let mut tree = teksilo_core::widget_tree::WidgetTree::new();
162        localize_standard_shortcuts(tree.shortcut_registry_mut());
163        assert_eq!(
164            shown(tree.shortcut_registry(), StandardEdit::PastePlain),
165            ("Paste Unformatted".to_string(), Some("Edit".to_string()))
166        );
167    }
168}