Skip to main content

teksilo_widgets/
shortcut_settings.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ShortcutSettings — user-facing widget for browsing and rebinding
5//! application shortcuts.
6//!
7//! Reads every shortcut registered in the tree's
8//! [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry) and
9//! renders one row per entry, grouped by category, with both primary
10//! and secondary keystrokes independently rebindable. Supports:
11//!
12//! - **Rebind** (primary or secondary) via one-shot key capture.
13//! - **Unbind** a slot explicitly (sets the override to `None`), or
14//!   press `Delete` / `Backspace` during capture.
15//! - **Reset** clears the user override entirely, restoring the
16//!   declared defaults. Disabled when no override exists.
17//! - **Conflict auto-resolution**: rebinding to a keystroke already
18//!   bound elsewhere silently unbinds the conflicting shortcut so
19//!   there's always exactly one binding per chord.
20//! - **Escape** during capture cancels without committing.
21//! - **Platform-aware keystroke labels** via [`format_keystroke`].
22//!
23//! The widget owns the currently-armed [`CaptureHandle`]; dropping
24//! the widget cancels the capture, so navigating away mid-rebind
25//! cannot leak a stray rebind onto the next keystroke pressed
26//! somewhere else in the app.
27//!
28//! ```ignore
29//! // Inside a settings Dialog build():
30//! let filter = ctx.signal(String::new());
31//! ctx.add(
32//!     ShortcutSettings::new()
33//!         .with_filter(filter)
34//!         .confirm_conflicts(true)
35//!         .on_conflict(|c| println!("displaced: {}", c.displaced_name)),
36//! );
37//! ```
38
39use std::cell::RefCell;
40use std::rc::Rc;
41use teksilo_i18n::lit;
42
43use teksilo_canvas::{Rect, Size, SizeProposal};
44use teksilo_core::accessibility::AccessNodeBuilder;
45use teksilo_core::binding::BindingLevel;
46use teksilo_core::build_context::BuildContext;
47use teksilo_core::event::{Key, Modifiers};
48use teksilo_core::shortcut::{CaptureHandle, KeyStroke};
49use teksilo_core::signal::Signal;
50use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
51use teksilo_core::widget_id::WidgetId;
52
53use crate::button::Button;
54use crate::keystroke_format::format_keystroke;
55use crate::primitives::{HStack, Spacer, TextWidget, VStack};
56use teksilo_tokens::{TextRole, TextStyleRole};
57
58/// Which keystroke slot a capture/rebind targets.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum SlotKind {
61    Primary,
62    Secondary,
63}
64
65/// Composite key identifying a pending capture: shortcut id + slot.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67struct CaptureTarget {
68    id: &'static str,
69    slot: SlotKind,
70}
71
72/// Describes a rebind that collides with an existing binding.
73///
74/// Passed to the [`ShortcutSettings::on_conflict`] callback so the app
75/// can surface a toast ("Save lost its Ctrl+S binding"); also used
76/// internally to drive the optional inline confirm prompt.
77#[derive(Debug, Clone)]
78pub struct ShortcutConflict {
79    /// Id of the shortcut that currently owns the chord and will be
80    /// unbound if the rebind proceeds.
81    pub displaced_id: String,
82    /// Display name of that shortcut (its registry `name`).
83    pub displaced_name: String,
84    /// The chord being assigned to the new target.
85    pub keystroke: KeyStroke,
86}
87
88/// A rebind held back pending user confirmation (confirm mode only).
89#[derive(Debug, Clone)]
90struct PendingRebind {
91    target_id: &'static str,
92    slot: SlotKind,
93    ks: KeyStroke,
94    conflict_id: String,
95    conflict_slot: Option<SlotKind>,
96    conflict_name: String,
97}
98
99/// A settings panel for browsing and rebinding application shortcuts.
100///
101/// Reads every `Shortcut` in the tree's `ShortcutRegistry`, groups rows
102/// by category, and renders primary + secondary keystroke slots with
103/// Rebind, Unbind, and Reset controls. See the module-level docs for the
104/// full feature list.
105pub struct ShortcutSettings {
106    /// Target of the current capture (`None` when idle). Drives the
107    /// "Press any key…" hint on the correct row + slot.
108    capturing: Signal<Option<CaptureTarget>>,
109    /// Live capture handle — dropped on widget destruction (or
110    /// replaced on the next rebind) to cancel the capture. Shared
111    /// with button closures via `Rc` so they can store the handle
112    /// returned by `ctx.begin_key_capture(...)`.
113    active_handle: Rc<RefCell<Option<CaptureHandle>>>,
114    /// Optional filter signal. When bound, rows are included only
115    /// when the substring matches (case-insensitive) the shortcut's
116    /// `name`, `id`, or `category`. Apps drive this from whatever
117    /// input they want — a text field, a chip bar, a command palette.
118    /// When `None`, every registered shortcut is shown.
119    filter: Option<Signal<String>>,
120    /// When `true`, a rebind that collides with an existing binding
121    /// surfaces an inline confirm prompt instead of silently unbinding
122    /// the other shortcut. Off by default (immediate reassignment).
123    confirm_conflicts: bool,
124    /// Fired whenever a rebind collides with an existing binding,
125    /// regardless of `confirm_conflicts`. Lets apps show a toast.
126    on_conflict: Option<Rc<dyn Fn(&ShortcutConflict)>>,
127    /// Holds a rebind awaiting confirmation (confirm mode). Drives the
128    /// inline "already assigned to X — Reassign / Cancel" prompt.
129    pending: Signal<Option<PendingRebind>>,
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    /// Create a settings panel that lists every shortcut currently
141    /// registered in the tree's `ShortcutRegistry`, without a filter.
142    pub fn new() -> Self {
143        Self {
144            capturing: Signal::new(None),
145            active_handle: Rc::new(RefCell::new(None)),
146            filter: None,
147            confirm_conflicts: false,
148            on_conflict: None,
149            pending: Signal::new(None),
150            root_child_id: None,
151        }
152    }
153
154    /// Bind the visible row set to a filter signal. The widget
155    /// shows only shortcuts whose `name`, `id`, or `category`
156    /// contains the filter text (case-insensitive). Empty string =
157    /// show everything.
158    ///
159    /// Apps typically drive this from a `TextInput` elsewhere in
160    /// their settings UI; keeping the filter external keeps this
161    /// widget's own surface minimal rather than embedding a search box.
162    pub fn with_filter(mut self, filter: Signal<String>) -> Self {
163        self.filter = Some(filter);
164        self
165    }
166
167    /// Require explicit confirmation before a rebind unbinds a
168    /// conflicting shortcut. Off by default — the chord is reassigned
169    /// immediately (the historical behavior). When on, a colliding
170    /// rebind shows an inline "already assigned to X — Reassign /
171    /// Cancel" prompt on the row, and the registry is left untouched
172    /// until the user confirms.
173    pub fn confirm_conflicts(mut self, yes: bool) -> Self {
174        self.confirm_conflicts = yes;
175        self
176    }
177
178    /// Register a callback fired whenever a rebind collides with an
179    /// existing binding — **regardless** of [`confirm_conflicts`]. The
180    /// callback receives the displaced shortcut's id, name, and the
181    /// chord, so the app can surface a toast ("Save lost its Ctrl+S
182    /// binding"). It fires before the displaced binding is removed (in
183    /// confirm mode, before the user has confirmed).
184    ///
185    /// [`confirm_conflicts`]: Self::confirm_conflicts
186    pub fn on_conflict(mut self, f: impl Fn(&ShortcutConflict) + 'static) -> Self {
187        self.on_conflict = Some(Rc::new(f));
188        self
189    }
190}
191
192impl std::fmt::Debug for ShortcutSettings {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("ShortcutSettings").finish()
195    }
196}
197
198impl Widget for ShortcutSettings {
199    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
200        // Rebuild on any registry change (register, rebind, clear).
201        ctx.shortcut_version().bind_to(
202            ctx.self_id(),
203            ctx.binding_registry(),
204            BindingLevel::Rebuild,
205        );
206        // Rebuild when capture state changes — the "Press…" hint
207        // jumps between rows.
208        self.capturing
209            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
210        // Rebuild when a conflict is queued / resolved — the inline
211        // confirm prompt appears and disappears on a row.
212        self.pending
213            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
214        // Rebuild when the filter signal changes.
215        if let Some(filter) = &self.filter {
216            filter.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
217        }
218
219        let filter_needle = self
220            .filter
221            .as_ref()
222            .map(|f| f.get().trim().to_lowercase())
223            .unwrap_or_default();
224        let matches_filter = |data: &ShortcutRowData| -> bool {
225            if filter_needle.is_empty() {
226                return true;
227            }
228            let hay_lower = |s: &str| s.to_lowercase();
229            hay_lower(&data.name).contains(&filter_needle)
230                || hay_lower(data.id).contains(&filter_needle)
231                || data
232                    .category
233                    .map(|c| hay_lower(c).contains(&filter_needle))
234                    .unwrap_or(false)
235        };
236
237        let mut rows: Vec<ShortcutRowData> = ctx
238            .shortcut_registry()
239            .iter_effective()
240            .map(|eff| ShortcutRowData {
241                id: eff.shortcut.id,
242                name: eff.shortcut.name.get(),
243                primary: eff.primary,
244                secondary: eff.secondary,
245                enabled: eff.enabled,
246                category: eff.shortcut.category,
247                has_override: ctx
248                    .shortcut_registry()
249                    .override_for(eff.shortcut.id)
250                    .is_some(),
251            })
252            .filter(matches_filter)
253            .collect();
254        // Stable order: category then id.
255        rows.sort_by(|a, b| a.category.cmp(&b.category).then(a.id.cmp(b.id)));
256
257        let capturing = self.capturing.get();
258        let pending = self.pending.get();
259        let mut column = VStack::new().spacing(4.0);
260
261        let mut last_category: Option<Option<&'static str>> = None;
262        for row in rows {
263            if last_category != Some(row.category) {
264                column = column.child(category_header(row.category));
265                last_category = Some(row.category);
266            }
267            let row_id = self.build_row(ctx, &row, capturing, pending.as_ref());
268            column = column.add_child(row_id);
269        }
270
271        let root = ctx.add(column);
272        self.root_child_id = Some(root);
273        vec![root]
274    }
275
276    fn layout_response(
277        &self,
278        proposal: SizeProposal,
279        ctx: &LayoutContext,
280    ) -> teksilo_core::widget::LayoutResponse {
281        self.root_child_id
282            .and_then(|id| ctx.child_size(id, proposal))
283            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
284            .into()
285    }
286
287    fn place_children(
288        &self,
289        bounds: Rect,
290        _proposal: SizeProposal,
291        children: &mut [WidgetPlacement],
292        _ctx: &LayoutContext,
293    ) {
294        for child in children.iter_mut() {
295            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
296            child.size = Size::new(bounds.width, bounds.height);
297        }
298    }
299
300    fn children(&self) -> Vec<WidgetId> {
301        self.root_child_id.into_iter().collect()
302    }
303
304    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
305        builder.set_role(teksilo_core::accesskit::Role::Group);
306        builder.set_name(
307            teksilo_i18n::tr_widget!(a11y_shortcut_settings_name())
308                .resolve_now()
309                .as_str(),
310        );
311    }
312}
313
314/// A thin wrapper used only by the row currently in key-capture mode.
315/// Emits `Role::Status` + `Live::Polite` so assistive tech announces
316/// the "Press any key…" hint the moment the capture row appears, and
317/// re-announces when the hint text changes (e.g. capture cancels).
318/// This sits in place of a plain `TextWidget` inside `slot_widget`.
319#[derive(Debug)]
320struct LiveStatusText {
321    text: String,
322    role: TextRole,
323    child_id: Option<WidgetId>,
324}
325
326impl LiveStatusText {
327    fn new(text: impl Into<String>, role: TextRole) -> Self {
328        Self {
329            text: text.into(),
330            role,
331            child_id: None,
332        }
333    }
334}
335
336impl Widget for LiveStatusText {
337    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
338        let id = ctx.add(
339            TextWidget::new(lit!(&self.text))
340                .color(self.role)
341                .single_line()
342                .a11y_hidden(),
343        );
344        self.child_id = Some(id);
345        vec![id]
346    }
347
348    fn layout_response(
349        &self,
350        proposal: SizeProposal,
351        ctx: &LayoutContext,
352    ) -> teksilo_core::widget::LayoutResponse {
353        self.child_id
354            .and_then(|id| ctx.child_size(id, proposal))
355            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
356            .into()
357    }
358
359    fn place_children(
360        &self,
361        bounds: Rect,
362        _proposal: SizeProposal,
363        children: &mut [WidgetPlacement],
364        _ctx: &LayoutContext,
365    ) {
366        for child in children.iter_mut() {
367            child.origin = bounds.origin();
368            child.size = bounds.size();
369        }
370    }
371
372    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
373        builder.set_role(teksilo_core::accesskit::Role::Status);
374        builder.set_name(self.text.as_str());
375        builder.set_live(teksilo_core::accesskit::Live::Polite);
376    }
377
378    fn children(&self) -> Vec<WidgetId> {
379        self.child_id.into_iter().collect()
380    }
381}
382
383struct ShortcutRowData {
384    id: &'static str,
385    name: String,
386    primary: Option<KeyStroke>,
387    secondary: Option<KeyStroke>,
388    enabled: bool,
389    category: Option<&'static str>,
390    has_override: bool,
391}
392
393fn category_header(category: Option<&'static str>) -> impl Widget + 'static {
394    let label = category.unwrap_or("General");
395    TextWidget::new(lit!(label))
396        .style(TextStyleRole::BodyBold)
397        .color(TextRole::Primary)
398        .single_line()
399}
400
401impl ShortcutSettings {
402    fn build_row(
403        &self,
404        ctx: &mut BuildContext,
405        row: &ShortcutRowData,
406        capturing: Option<CaptureTarget>,
407        pending: Option<&PendingRebind>,
408    ) -> WidgetId {
409        let id = row.id;
410        // The label and keystroke text use `TextRole::Primary`
411        // unconditionally; the leaves consult
412        // `PaintContext::effective_enabled` and substitute
413        // `TextRole::Disabled` on their own when the arena says the
414        // row is disabled (either via the registry-driven flag below
415        // or via an ancestor's `enabled_when`).
416        let name_widget = TextWidget::new(lit!(&row.name))
417            .color(TextRole::Primary)
418            .single_line();
419
420        let primary_slot = self.slot_widget(id, SlotKind::Primary, row.primary, capturing, pending);
421        let secondary_slot =
422            self.slot_widget(id, SlotKind::Secondary, row.secondary, capturing, pending);
423
424        let reset_button = Button::new(lit!("Reset"))
425            .enabled(row.has_override)
426            .on_activate_fn(move |ctx: &mut EventContext| {
427                ctx.clear_shortcut_override(id);
428            });
429
430        let row_widget = HStack::new()
431            .spacing(8.0)
432            .child(name_widget)
433            .child(Spacer::new())
434            .child(primary_slot)
435            .child(secondary_slot)
436            .child(reset_button);
437        let row_id = ctx.add(row_widget);
438        // Bridge the registry-driven per-shortcut enabled flag into
439        // the arena. The arena is then the single source of truth:
440        // descendants AND with this node, the leaves auto-substitute
441        // `TextRole::Disabled`, the Reset Button's tap handler is
442        // gated, and the framework a11y walker emits `set_disabled`
443        // on every descendant. An ancestor's `enabled_when` (e.g. a
444        // disabled settings dialog) cascades correctly.
445        if !row.enabled {
446            ctx.enabled_when(row_id, false);
447        }
448        row_id
449    }
450
451    fn slot_widget(
452        &self,
453        id: &'static str,
454        slot: SlotKind,
455        keystroke: Option<KeyStroke>,
456        capturing: Option<CaptureTarget>,
457        pending: Option<&PendingRebind>,
458    ) -> impl Widget + 'static {
459        let is_capturing_here = capturing == Some(CaptureTarget { id, slot });
460        // Confirm mode: a rebind on this slot is awaiting the user's OK.
461        let pending_here = pending
462            .filter(|p| p.target_id == id && p.slot == slot)
463            .cloned();
464        let keystroke_text = if is_capturing_here {
465            teksilo_i18n::tr_widget!(a11y_shortcut_settings_capture_hint()).resolve_now()
466        } else {
467            keystroke
468                .map(format_keystroke)
469                .unwrap_or_else(|| "—".to_string())
470        };
471
472        let slot_label = match slot {
473            SlotKind::Primary => "Rebind",
474            SlotKind::Secondary => "Rebind 2nd",
475        };
476
477        let confirm = self.confirm_conflicts;
478        let on_conflict = self.on_conflict.clone();
479        let pending_signal = self.pending.clone();
480        let rebind_button = {
481            let capturing_signal = self.capturing.clone();
482            let handle_cell = self.active_handle.clone();
483            let pending_for_cb = pending_signal.clone();
484            Button::new(lit!(slot_label)).on_activate_fn(move |ctx: &mut EventContext| {
485                let target = CaptureTarget { id, slot };
486                capturing_signal.set(Some(target));
487                let cap_for_cb = capturing_signal.clone();
488                let on_conflict = on_conflict.clone();
489                let pending_for_cb = pending_for_cb.clone();
490                let handle = ctx.begin_key_capture(move |ks, reg, _cap_ctx| {
491                    handle_capture_event(
492                        ks,
493                        reg,
494                        id,
495                        slot,
496                        confirm,
497                        on_conflict.as_ref(),
498                        &pending_for_cb,
499                    );
500                    cap_for_cb.set(None);
501                });
502                // Replacing any prior handle drops it — cancelling a
503                // stale capture from a previous click in the same
504                // session.
505                *handle_cell.borrow_mut() = Some(handle);
506            })
507        };
508
509        // While capturing, the hint cell is a `Role::Status` +
510        // `Live::Polite` wrapper so screen readers announce
511        // "Press any key…" the moment the user hits Rebind. Static
512        // bindings stay as plain labels — their content is announced
513        // on focus, not as a live change.
514        //
515        // The keystroke label uses `TextRole::Primary`
516        // unconditionally; the surrounding row's `enabled_when`
517        // forwarding bridges the registry-driven enabled flag into
518        // the arena, and the leaf substitutes `TextRole::Disabled`
519        // via `PaintContext::effective_enabled` when the row is off.
520        let row = HStack::new().spacing(4.0);
521        let row = if is_capturing_here {
522            row.child(LiveStatusText::new(keystroke_text, TextRole::Accent))
523        } else {
524            row.child(
525                TextWidget::new(lit!(&keystroke_text))
526                    .color(TextRole::Primary)
527                    .single_line(),
528            )
529        };
530
531        // Confirm prompt takes over the slot's trailing controls while a
532        // conflicting rebind is queued: announce the collision and offer
533        // Reassign / Cancel instead of a fresh Rebind.
534        let Some(p) = pending_here else {
535            return row.child(rebind_button);
536        };
537
538        let warning = format!(
539            "{} is assigned to {}",
540            format_keystroke(p.ks),
541            p.conflict_name
542        );
543        let reassign = {
544            let pending_signal = pending_signal.clone();
545            let p = p.clone();
546            Button::new(lit!("Reassign")).on_activate_fn(move |ctx: &mut EventContext| {
547                // Unbind the conflicting slot, then claim the chord.
548                match p.conflict_slot {
549                    Some(SlotKind::Primary) => {
550                        ctx.rebind_shortcut_primary(p.conflict_id.clone(), None)
551                    }
552                    Some(SlotKind::Secondary) => {
553                        ctx.rebind_shortcut_secondary(p.conflict_id.clone(), None)
554                    }
555                    None => {}
556                }
557                match p.slot {
558                    SlotKind::Primary => ctx.rebind_shortcut_primary(p.target_id, Some(p.ks)),
559                    SlotKind::Secondary => ctx.rebind_shortcut_secondary(p.target_id, Some(p.ks)),
560                }
561                pending_signal.set(None);
562            })
563        };
564        let cancel = {
565            let pending_signal = pending_signal.clone();
566            Button::new(lit!("Cancel")).on_activate_fn(move |_ctx: &mut EventContext| {
567                pending_signal.set(None);
568            })
569        };
570        row.child(LiveStatusText::new(warning, TextRole::Accent))
571            .child(reassign)
572            .child(cancel)
573    }
574}
575
576/// Apply the intent of a captured chord to the registry: bare Escape
577/// cancels, bare Delete/Backspace unbinds, everything else rebinds.
578///
579/// On a chord that collides with another shortcut, the `on_conflict`
580/// callback fires (if set). Then, if `confirm` is `false`, the
581/// conflicting shortcut is unbound and the rebind applied immediately
582/// (the historical behavior); if `confirm` is `true`, nothing is
583/// mutated — the rebind is parked in `pending` and an inline
584/// Reassign / Cancel prompt is surfaced for the user to confirm.
585fn handle_capture_event(
586    ks: KeyStroke,
587    reg: &mut teksilo_core::shortcut::ShortcutRegistry,
588    id: &'static str,
589    slot: SlotKind,
590    confirm: bool,
591    on_conflict: Option<&Rc<dyn Fn(&ShortcutConflict)>>,
592    pending: &Signal<Option<PendingRebind>>,
593) {
594    if ks.key == Key::Escape && ks.modifiers == Modifiers::NONE {
595        return; // cancel
596    }
597    if matches!(ks.key, Key::Delete | Key::Backspace) && ks.modifiers == Modifiers::NONE {
598        match slot {
599            SlotKind::Primary => reg.rebind_primary(id, None),
600            SlotKind::Secondary => reg.rebind_secondary(id, None),
601        }
602        return;
603    }
604    // Detect a collision (a different shortcut already owning the chord).
605    // Resolve to an owned id immediately so the immutable borrow of `reg`
606    // ends before any mutation below.
607    let cid = reg.find_conflict(ks, Some(id)).map(|c| c.to_string());
608    if let Some(cid) = cid {
609        let conflict_slot = reg.effective(&cid).and_then(|eff| {
610            if eff.primary == Some(ks) {
611                Some(SlotKind::Primary)
612            } else if eff.secondary == Some(ks) {
613                Some(SlotKind::Secondary)
614            } else {
615                None
616            }
617        });
618        let conflict_name = reg
619            .iter_effective()
620            .find(|e| e.shortcut.id == cid)
621            .map(|e| e.shortcut.name.get())
622            .unwrap_or_else(|| cid.clone());
623
624        if let Some(cb) = on_conflict {
625            cb(&ShortcutConflict {
626                displaced_id: cid.clone(),
627                displaced_name: conflict_name.clone(),
628                keystroke: ks,
629            });
630        }
631
632        if confirm {
633            // Defer everything: park the rebind for explicit confirmation.
634            pending.set(Some(PendingRebind {
635                target_id: id,
636                slot,
637                ks,
638                conflict_id: cid,
639                conflict_slot,
640                conflict_name,
641            }));
642            return;
643        }
644
645        // Immediate mode: auto-unbind the conflicting shortcut so there's
646        // always exactly one effective binding per chord.
647        match conflict_slot {
648            Some(SlotKind::Primary) => reg.rebind_primary(cid, None),
649            Some(SlotKind::Secondary) => reg.rebind_secondary(cid, None),
650            None => {}
651        }
652    }
653    match slot {
654        SlotKind::Primary => reg.rebind_primary(id, Some(ks)),
655        SlotKind::Secondary => reg.rebind_secondary(id, Some(ks)),
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use teksilo_core::shortcut::{Shortcut, ShortcutRegistry};
663    use teksilo_core::widget_tree::WidgetTree;
664
665    /// Immediate-mode capture (no confirm, no callback) — the historical
666    /// behavior most tests exercise.
667    fn apply_capture(reg: &mut ShortcutRegistry, ks: KeyStroke, id: &'static str, slot: SlotKind) {
668        handle_capture_event(ks, reg, id, slot, false, None, &Signal::new(None));
669    }
670
671    #[test]
672    fn shortcut_settings_builds_a_row_per_registered_shortcut() {
673        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
674        tree.shortcut_registry_mut().register(
675            Shortcut::new("app.save")
676                .name("Save")
677                .primary(KeyStroke::command(Key::S))
678                .build(),
679        );
680        tree.shortcut_registry_mut().register(
681            Shortcut::new("app.open")
682                .name("Open")
683                .primary(KeyStroke::command(Key::O))
684                .build(),
685        );
686        let settings = tree.add(ShortcutSettings::new());
687        tree.layout(SizeProposal::exact(900.0, 600.0));
688        let b = tree.bounds(settings);
689        assert!(b.width > 0.0 && b.height > 0.0);
690    }
691
692    #[test]
693    fn delete_during_capture_unbinds_primary_slot() {
694        let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
695        reg.register(
696            Shortcut::new("app.save")
697                .primary(KeyStroke::command(Key::S))
698                .build(),
699        );
700        // Delete with no modifiers during capture → primary = None.
701        apply_capture(
702            &mut reg,
703            KeyStroke::new(Key::Delete, Modifiers::NONE),
704            "app.save",
705            SlotKind::Primary,
706        );
707        assert_eq!(reg.effective("app.save").unwrap().primary, None);
708    }
709
710    #[test]
711    fn escape_during_capture_is_cancel_not_rebind() {
712        let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
713        reg.register(
714            Shortcut::new("app.save")
715                .primary(KeyStroke::command(Key::S))
716                .build(),
717        );
718        apply_capture(
719            &mut reg,
720            KeyStroke::new(Key::Escape, Modifiers::NONE),
721            "app.save",
722            SlotKind::Primary,
723        );
724        // Still the default — escape must not mutate anything.
725        assert_eq!(
726            reg.effective("app.save").unwrap().primary,
727            Some(KeyStroke::command(Key::S))
728        );
729    }
730
731    #[test]
732    fn rebind_auto_unbinds_conflicting_shortcut() {
733        let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
734        reg.register(
735            Shortcut::new("app.save")
736                .primary(KeyStroke::command(Key::S))
737                .build(),
738        );
739        reg.register(
740            Shortcut::new("app.sync")
741                .primary(KeyStroke::command(Key::K))
742                .build(),
743        );
744        // User rebinds app.sync to Ctrl+S (which app.save owns).
745        apply_capture(
746            &mut reg,
747            KeyStroke::command(Key::S),
748            "app.sync",
749            SlotKind::Primary,
750        );
751        assert_eq!(
752            reg.effective("app.sync").unwrap().primary,
753            Some(KeyStroke::command(Key::S)),
754            "sync takes the new chord"
755        );
756        assert_eq!(
757            reg.effective("app.save").unwrap().primary,
758            None,
759            "save is auto-unbound on conflict"
760        );
761    }
762
763    #[test]
764    fn rebind_auto_unbinds_conflict_on_secondary_slot() {
765        let mut reg = teksilo_core::shortcut::ShortcutRegistry::new();
766        reg.register(
767            Shortcut::new("edit.undo")
768                .primary(KeyStroke::command(Key::Z))
769                .secondary(KeyStroke::alt(Key::Backspace))
770                .build(),
771        );
772        reg.register(Shortcut::new("edit.redo").build());
773        // User rebinds edit.redo.primary to Alt+Backspace — undo's
774        // secondary slot currently owns that chord.
775        apply_capture(
776            &mut reg,
777            KeyStroke::alt(Key::Backspace),
778            "edit.redo",
779            SlotKind::Primary,
780        );
781        assert_eq!(
782            reg.effective("edit.redo").unwrap().primary,
783            Some(KeyStroke::alt(Key::Backspace))
784        );
785        let undo = reg.effective("edit.undo").unwrap();
786        assert_eq!(undo.primary, Some(KeyStroke::command(Key::Z)));
787        assert_eq!(
788            undo.secondary, None,
789            "the conflicting secondary slot is the one auto-unbound"
790        );
791    }
792
793    #[test]
794    fn confirm_mode_defers_the_rebind_and_parks_a_pending_conflict() {
795        let mut reg = ShortcutRegistry::new();
796        reg.register(
797            Shortcut::new("app.save")
798                .name("Save")
799                .primary(KeyStroke::command(Key::S))
800                .build(),
801        );
802        reg.register(
803            Shortcut::new("app.sync")
804                .primary(KeyStroke::command(Key::K))
805                .build(),
806        );
807        let pending: Signal<Option<PendingRebind>> = Signal::new(None);
808        // Confirm mode: rebinding app.sync to Ctrl+S must NOT mutate the
809        // registry; it parks a pending conflict instead.
810        handle_capture_event(
811            KeyStroke::command(Key::S),
812            &mut reg,
813            "app.sync",
814            SlotKind::Primary,
815            true,
816            None,
817            &pending,
818        );
819        assert_eq!(
820            reg.effective("app.save").unwrap().primary,
821            Some(KeyStroke::command(Key::S)),
822            "save keeps its binding until the user confirms"
823        );
824        assert_eq!(
825            reg.effective("app.sync").unwrap().primary,
826            Some(KeyStroke::command(Key::K)),
827            "sync is unchanged until the user confirms"
828        );
829        let p = pending.get().expect("a pending rebind is parked");
830        assert_eq!(p.target_id, "app.sync");
831        assert_eq!(p.conflict_id, "app.save");
832        assert_eq!(p.conflict_slot, Some(SlotKind::Primary));
833        assert_eq!(p.conflict_name, "Save");
834    }
835
836    #[test]
837    fn on_conflict_callback_fires_with_displaced_shortcut() {
838        let mut reg = ShortcutRegistry::new();
839        reg.register(
840            Shortcut::new("app.save")
841                .name("Save")
842                .primary(KeyStroke::command(Key::S))
843                .build(),
844        );
845        reg.register(
846            Shortcut::new("app.sync")
847                .primary(KeyStroke::command(Key::K))
848                .build(),
849        );
850        let seen: Rc<RefCell<Option<ShortcutConflict>>> = Rc::new(RefCell::new(None));
851        let cb_seen = seen.clone();
852        let cb: Rc<dyn Fn(&ShortcutConflict)> =
853            Rc::new(move |c: &ShortcutConflict| *cb_seen.borrow_mut() = Some(c.clone()));
854        let pending: Signal<Option<PendingRebind>> = Signal::new(None);
855        // Immediate mode (confirm = false): callback still fires.
856        handle_capture_event(
857            KeyStroke::command(Key::S),
858            &mut reg,
859            "app.sync",
860            SlotKind::Primary,
861            false,
862            Some(&cb),
863            &pending,
864        );
865        let c = seen.borrow().clone().expect("callback fired");
866        assert_eq!(c.displaced_id, "app.save");
867        assert_eq!(c.displaced_name, "Save");
868        assert_eq!(c.keystroke, KeyStroke::command(Key::S));
869        // And the immediate rebind still happened.
870        assert_eq!(reg.effective("app.save").unwrap().primary, None);
871        assert_eq!(
872            reg.effective("app.sync").unwrap().primary,
873            Some(KeyStroke::command(Key::S))
874        );
875    }
876
877    #[test]
878    fn filter_narrows_visible_rows_by_name_or_category() {
879        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
880        tree.shortcut_registry_mut().register(
881            Shortcut::new("app.save")
882                .name("Save")
883                .category("File")
884                .primary(KeyStroke::command(Key::S))
885                .build(),
886        );
887        tree.shortcut_registry_mut().register(
888            Shortcut::new("edit.bold")
889                .name("Bold")
890                .category("Format")
891                .primary(KeyStroke::command(Key::B))
892                .build(),
893        );
894        tree.shortcut_registry_mut().register(
895            Shortcut::new("edit.italic")
896                .name("Italic")
897                .category("Format")
898                .primary(KeyStroke::command(Key::I))
899                .build(),
900        );
901
902        let filter = Signal::new(String::from("format"));
903        let settings = tree.add(ShortcutSettings::new().with_filter(filter.clone()));
904        tree.layout(SizeProposal::exact(900.0, 600.0));
905
906        // With filter = "format", only Format-category rows
907        // (edit.bold, edit.italic) should appear, plus one header.
908        // Snapshot the size before changing the filter; a non-zero
909        // bounds proves some rows rendered.
910        let before = tree.bounds(settings);
911        assert!(before.height > 0.0);
912
913        // Widen to match all three — widget should grow.
914        filter.set(String::new());
915        tree.layout(SizeProposal::exact(900.0, 600.0));
916        let after = tree.bounds(settings);
917        assert!(
918            after.height >= before.height,
919            "clearing filter must not shrink the widget (got {} → {})",
920            before.height,
921            after.height
922        );
923    }
924
925    #[test]
926    fn rebind_through_capture_mode_updates_registry() {
927        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
928        tree.shortcut_registry_mut().register(
929            Shortcut::new("app.save")
930                .name("Save")
931                .primary(KeyStroke::command(Key::S))
932                .build(),
933        );
934        let _settings = tree.add(ShortcutSettings::new());
935        tree.layout(SizeProposal::exact(900.0, 600.0));
936
937        let _h = tree.begin_key_capture(|ks, reg, _ctx| {
938            reg.rebind_primary("app.save", Some(ks));
939        });
940        // leak the handle through `_h = ManuallyDrop::new(...)` — actually
941        // we keep it alive by not dropping it explicitly at end of scope.
942        let h = _h;
943        tree.press_key(Key::B, Modifiers::COMMAND | Modifiers::SHIFT);
944        drop(h);
945
946        assert_eq!(
947            tree.shortcut_registry()
948                .effective("app.save")
949                .unwrap()
950                .primary,
951            Some(KeyStroke::command_shift(Key::B))
952        );
953    }
954}