Skip to main content

teksilo_widgets/
shortcut_settings.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ShortcutSettings: the user-facing panel for browsing and rebinding an
5//! application's shortcuts.
6//!
7//! It lists every shortcut in the tree's
8//! [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry), grouped
9//! by category, one row per command:
10//!
11//! ```text
12//!  [ Search commands or keys         ] [⌨]  [ ] Modified only  [Reset all…]
13//!
14//!  File
15//!     New project                 [Ctrl] [N]          +
16//!     Open…                       [Ctrl] [O]          +
17//!   ● Save                        [Ctrl] [Shift] [S]  [Ctrl] [S]   ↺
18//!  Format
19//!     Bold                        ( Press keys… )     +
20//!        Press the new shortcut. Esc cancels, Del removes it.
21//! ```
22//!
23//! - **The chord is the control.** Each chord is drawn as key caps; click
24//!   it (or focus it and press Enter or Space) to record a new one. The
25//!   first column is the shortcut, the second its alternative (`+` adds
26//!   one). Delete or Backspace on a focused chord removes it.
27//! - **Recording** shows "Press keys…" in a field the width of its column,
28//!   so the list never reflows. Escape or Tab cancels; Delete or Backspace
29//!   removes the chord; leaving the field cancels too. A key that cannot be
30//!   a shortcut is refused with the reason, and recording goes on: a
31//!   printable key needs Ctrl, Alt or ⌘ (Shift alone types a capital), and
32//!   chords the system keeps (Alt+F4, Alt+Tab, ⌘Tab, ⌘Space, …) and
33//!   Ctrl+Tab (the way out of a control that keeps Tab) are reserved.
34//! - **Conflicts are confirmed.** A chord another command already uses
35//!   is not taken from it until the user presses "Reassign" on the line
36//!   that appears under the row. [`confirm_conflicts(false)`] reassigns at
37//!   once instead; either way [`on_conflict`] is told.
38//! - **Changed rows are marked** with a dot and get a reset button, which
39//!   puts that command's declared defaults back. "Reset all…" in the
40//!   toolbar does it for every command, after a confirmation.
41//! - **Search** matches a command's name, its group, its description and
42//!   the text of its chords ("ctrl+s" finds Save). The keyboard button
43//!   next to the field finds by chord instead: press it, press the keys,
44//!   and the list shows the commands that use exactly that chord.
45//!   "Modified only" narrows the list to the commands the user changed.
46//!
47//! Commands are sorted by name within a group and groups by their title,
48//! both in the interface language. Every visible string comes from the
49//! framework's locale bundles.
50//!
51//! **Keyboard.** The list is one Tab stop. The arrow keys move between rows
52//! and between a row's chord, alternative and reset button; Home and End go
53//! to the first and last row. Every control carries its command in its
54//! accessible name ("Save: Ctrl+S"), its description and whether the user
55//! changed it in its description, and the group titles are headings.
56//!
57//! The widget owns the armed key capture. Dropping the widget cancels it,
58//! so navigating away mid-recording cannot rebind the next key pressed
59//! somewhere else.
60//!
61//! The list grows with its content and does not scroll; place the widget
62//! in a `ScrollArea` when there are more shortcuts than fit.
63//!
64//! ```ignore
65//! // Inside a settings Dialog build():
66//! ctx.add(
67//!     ShortcutSettings::new()
68//!         .on_conflict(|c| println!("{} loses its shortcut", c.displaced_name)),
69//! );
70//! ```
71//!
72//! [`confirm_conflicts(false)`]: ShortcutSettings::confirm_conflicts
73//! [`on_conflict`]: ShortcutSettings::on_conflict
74
75mod controls;
76mod state;
77mod table;
78#[cfg(test)]
79mod tests;
80
81use std::rc::Rc;
82
83use teksilo_canvas::{Rect, SizeProposal};
84use teksilo_core::accessibility::AccessNodeBuilder;
85use teksilo_core::build_context::BuildContext;
86use teksilo_core::shortcut::KeyStroke;
87use teksilo_core::signal::Signal;
88use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
89use teksilo_core::widget_id::WidgetId;
90use teksilo_i18n::tr_widget;
91
92use self::state::{FocusKey, Shared, start_finding};
93use self::table::ShortcutTable;
94use crate::button::Button;
95use crate::checkbox::Checkbox;
96use crate::icon_button::{BuiltInIcons, IconButton};
97use crate::primitives::{FixedSize, HStack, Shrinkable, Spacer, VStack};
98use crate::search_field::SearchField;
99
100/// The search field's width in the toolbar.
101const SEARCH_WIDTH: f32 = 320.0;
102/// The narrowest the search field gets on a narrow panel.
103const SEARCH_MIN_WIDTH: f32 = 160.0;
104
105/// Describes a rebind that collides with an existing binding.
106///
107/// Passed to the [`ShortcutSettings::on_conflict`] callback so the app
108/// can surface a toast ("Save lost its Ctrl+S binding").
109#[derive(Debug, Clone)]
110pub struct ShortcutConflict {
111    /// Id of the shortcut that currently owns the chord and loses it if the
112    /// rebind goes ahead.
113    pub displaced_id: String,
114    /// Display name of that shortcut (its registry `name`).
115    pub displaced_name: String,
116    /// The chord being assigned to the new target.
117    pub keystroke: KeyStroke,
118}
119
120/// A settings panel for browsing and rebinding application shortcuts.
121///
122/// See the module documentation for what it shows and how it behaves.
123pub struct ShortcutSettings {
124    filter: Option<Signal<String>>,
125    modified_only: Option<Signal<bool>>,
126    toolbar: bool,
127    confirm_conflicts: bool,
128    on_conflict: Option<Rc<dyn Fn(&ShortcutConflict)>>,
129    shared: Option<Rc<Shared>>,
130    root_child_id: Option<WidgetId>,
131}
132
133impl Default for ShortcutSettings {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl ShortcutSettings {
140    /// A panel listing every shortcut registered in the tree's
141    /// `ShortcutRegistry`, with its toolbar, asking before it takes a chord
142    /// from another command.
143    pub fn new() -> Self {
144        Self {
145            filter: None,
146            modified_only: None,
147            toolbar: true,
148            confirm_conflicts: true,
149            on_conflict: None,
150            shared: None,
151            root_child_id: None,
152        }
153    }
154
155    /// Bind the search text to `filter`. The toolbar's search field edits
156    /// this signal, and anything else may write it too: a command palette
157    /// handing a query over, an app's own search box when the toolbar is
158    /// off. The list shows the shortcuts whose name, group, description or
159    /// chord contains the text (case-insensitive); an empty string shows
160    /// everything.
161    pub fn with_filter(mut self, filter: Signal<String>) -> Self {
162        self.filter = Some(filter);
163        self
164    }
165
166    /// Bind "Modified only" to `signal`: while it is `true` the list shows
167    /// only the shortcuts the user changed. The toolbar's checkbox edits
168    /// the same signal.
169    pub fn modified_only(mut self, signal: Signal<bool>) -> Self {
170        self.modified_only = Some(signal);
171        self
172    }
173
174    /// Show the toolbar (search field, find by keys, "Modified only",
175    /// "Reset all…"). On by default. Turn it off when the surrounding UI
176    /// already has a search field, and drive the list through
177    /// [`with_filter`](Self::with_filter) and
178    /// [`modified_only`](Self::modified_only).
179    pub fn toolbar(mut self, show: bool) -> Self {
180        self.toolbar = show;
181        self
182    }
183
184    /// Ask before a rebind takes a chord from another shortcut. On by
185    /// default: the colliding rebind shows "{chord} is already used by
186    /// {command}" under the row with Reassign and Cancel, and nothing
187    /// changes until the user answers. With `false` the other shortcut
188    /// loses the chord at once, and a screen reader is told which one.
189    pub fn confirm_conflicts(mut self, yes: bool) -> Self {
190        self.confirm_conflicts = yes;
191        self
192    }
193
194    /// Register a callback fired whenever a rebind collides with an
195    /// existing binding, **regardless** of [`confirm_conflicts`]. The
196    /// callback receives the displaced shortcut's id, name, and the
197    /// chord, so the app can surface a toast. It fires before the
198    /// displaced binding is removed (when asking first, before the user
199    /// has answered).
200    ///
201    /// [`confirm_conflicts`]: Self::confirm_conflicts
202    pub fn on_conflict(mut self, f: impl Fn(&ShortcutConflict) + 'static) -> Self {
203        self.on_conflict = Some(Rc::new(f));
204        self
205    }
206
207    fn toolbar_row(&self, ctx: &mut BuildContext, shared: &Rc<Shared>) -> WidgetId {
208        let search = ctx.add(
209            SearchField::new(shared.filter.clone())
210                .placeholder(tr_widget!(shortcut_settings_search_placeholder()))
211                .label(tr_widget!(shortcut_settings_search_label())),
212        );
213        shared.search_field.set(Some(search));
214
215        let find = {
216            let shared = shared.clone();
217            IconButton::new((BuiltInIcons::global().keyboard)())
218                .toggle(shared.finding.clone())
219                .tooltip(tr_widget!(shortcut_settings_find_by_keys()))
220                .on_activate_fn(move |ctx: &mut EventContext| {
221                    // The toggle has already flipped `finding`; put it back
222                    // and let `start_finding` decide from the state it saw.
223                    shared.finding.set(!shared.finding.get());
224                    start_finding(&shared, ctx);
225                })
226        };
227
228        let modified_only = Checkbox::new(shared.modified_only.clone())
229            .label(tr_widget!(shortcut_settings_modified_only()));
230
231        let reset_all = {
232            let shared = shared.clone();
233            Button::new(tr_widget!(shortcut_settings_reset_all()))
234                .enabled(shared.has_overrides.clone())
235                .on_activate_fn(move |_ctx: &mut EventContext| {
236                    shared.cancel_capture();
237                    shared.focus.set(Some(FocusKey::ResetAllCancel));
238                    shared.confirm_reset_all.set(true);
239                })
240        };
241        let reset_all = ctx.add(reset_all);
242        shared.reset_all_button.set(Some(reset_all));
243
244        // The field has a width of its own rather than the toolbar's: the
245        // list under it stops where its last column ends, and a field running
246        // on to the far edge would leave the toolbar's controls stranded
247        // there, away from the rows they act on. It gives way on a narrow
248        // panel before anything else does.
249        let search = Shrinkable::new()
250            .min_width(SEARCH_MIN_WIDTH)
251            .child(FixedSize::new().width(SEARCH_WIDTH).child(search));
252        ctx.add(
253            HStack::new()
254                .spacing(8.0)
255                .child(search)
256                .child(find)
257                .child(modified_only)
258                .child(reset_all)
259                .child(Spacer::new()),
260        )
261    }
262}
263
264impl std::fmt::Debug for ShortcutSettings {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("ShortcutSettings").finish()
267    }
268}
269
270impl Widget for ShortcutSettings {
271    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
272        let shared = Rc::new(Shared::new(
273            self.filter
274                .clone()
275                .unwrap_or_else(|| Signal::new(String::new())),
276            self.modified_only
277                .clone()
278                .unwrap_or_else(|| Signal::new(false)),
279            self.confirm_conflicts,
280            self.on_conflict.clone(),
281        ));
282        // A rebuild replaces the state, and dropping the old one drops any
283        // capture it had armed.
284        self.shared = Some(shared.clone());
285
286        let mut column = VStack::new().spacing(12.0);
287        if self.toolbar {
288            column = column.child(self.toolbar_row(ctx, &shared));
289        }
290        column = column.child(ShortcutTable::new(shared));
291        let root = ctx.add(column);
292        self.root_child_id = Some(root);
293        vec![root]
294    }
295
296    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
297        self.root_child_id
298            .and_then(|id| ctx.child_size(id, proposal))
299            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
300            .into()
301    }
302
303    fn place_children(
304        &self,
305        bounds: Rect,
306        _proposal: SizeProposal,
307        children: &mut [WidgetPlacement],
308        _ctx: &LayoutContext,
309    ) {
310        for child in children.iter_mut() {
311            child.origin = bounds.origin();
312            child.size = bounds.size();
313        }
314    }
315
316    fn children(&self) -> Vec<WidgetId> {
317        self.root_child_id.into_iter().collect()
318    }
319
320    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
321        builder.set_role(teksilo_core::accesskit::Role::Group);
322        builder.set_name(
323            tr_widget!(a11y_shortcut_settings_name())
324                .resolve_now()
325                .as_str(),
326        );
327    }
328
329    fn as_any(&self) -> Option<&dyn std::any::Any> {
330        Some(self)
331    }
332
333    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
334        Some(self)
335    }
336}