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}